up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -0,0 +1,220 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Extended compile output metadata for policy analysis, coverage tracking, and editor support.
/// </summary>
public sealed record PolicyCompileMetadata(
PolicySymbolTable SymbolTable,
PolicyRuleIndex RuleIndex,
PolicyDocumentation Documentation,
PolicyRuleCoverageMetadata CoverageMetadata,
PolicyDeterministicHashes Hashes);
/// <summary>
/// Deterministic hashes for policy identity and change detection.
/// </summary>
public sealed record PolicyDeterministicHashes(
/// <summary>SHA256 of canonical IR JSON representation.</summary>
string ContentHash,
/// <summary>SHA256 of rule structure only (names, priorities, conditions).</summary>
string StructureHash,
/// <summary>SHA256 of rule names and priorities (for ordering verification).</summary>
string OrderingHash,
/// <summary>Combined hash for complete identity verification.</summary>
string IdentityHash);
/// <summary>
/// Symbol table containing all identifiers, functions, and their usages.
/// </summary>
public sealed record PolicySymbolTable(
ImmutableArray<PolicySymbol> Symbols,
ImmutableArray<PolicyFunctionSignature> BuiltInFunctions,
ImmutableArray<PolicyVariableDefinition> Variables,
ImmutableDictionary<string, ImmutableArray<PolicySymbolReference>> ReferencesByName);
/// <summary>
/// A symbol in the policy DSL (identifier, function, variable, etc.).
/// </summary>
public sealed record PolicySymbol(
string Name,
PolicySymbolKind Kind,
string? Type,
PolicySymbolScope Scope,
ImmutableArray<PolicySymbolReference> References);
/// <summary>
/// Symbol kinds in the policy DSL.
/// </summary>
public enum PolicySymbolKind
{
Variable,
Function,
Profile,
ProfileMap,
ProfileEnv,
ProfileScalar,
Rule,
Metadata,
Setting,
Parameter,
BuiltIn
}
/// <summary>
/// Symbol scope information.
/// </summary>
public sealed record PolicySymbolScope(
string? RuleName,
string? ProfileName,
bool IsGlobal);
/// <summary>
/// Reference to a symbol usage in the policy.
/// </summary>
public sealed record PolicySymbolReference(
string SymbolName,
string Context,
int? LineNumber,
int? ColumnNumber,
PolicySymbolUsage Usage);
/// <summary>
/// How a symbol is used.
/// </summary>
public enum PolicySymbolUsage
{
Definition,
Read,
Write,
Invocation,
MemberAccess
}
/// <summary>
/// Built-in function signature for autocomplete.
/// </summary>
public sealed record PolicyFunctionSignature(
string Name,
string Description,
ImmutableArray<PolicyParameterInfo> Parameters,
string ReturnType,
ImmutableArray<string> Examples);
/// <summary>
/// Parameter information for function signatures.
/// </summary>
public sealed record PolicyParameterInfo(
string Name,
string Type,
bool IsOptional,
string? DefaultValue,
string Description);
/// <summary>
/// Variable definition extracted from policy.
/// </summary>
public sealed record PolicyVariableDefinition(
string Name,
string? InferredType,
string? InitialValue,
string DefinedInRule,
bool IsAssignment);
/// <summary>
/// Rule index for fast lookup and editor autocomplete.
/// </summary>
public sealed record PolicyRuleIndex(
ImmutableArray<PolicyRuleEntry> Rules,
ImmutableDictionary<string, PolicyRuleEntry> ByName,
ImmutableDictionary<int, ImmutableArray<PolicyRuleEntry>> ByPriority,
ImmutableArray<string> ActionTypes,
ImmutableArray<string> UsedIdentifiers);
/// <summary>
/// Index entry for a single rule.
/// </summary>
public sealed record PolicyRuleEntry(
string Name,
int Priority,
int Index,
string ConditionSummary,
ImmutableArray<string> ThenActionTypes,
ImmutableArray<string> ElseActionTypes,
string Justification,
ImmutableArray<string> ReferencedIdentifiers,
ImmutableArray<string> ReferencedFunctions);
/// <summary>
/// Extracted documentation from policy source.
/// </summary>
public sealed record PolicyDocumentation(
string? PolicyDescription,
ImmutableArray<string> Tags,
string? Author,
ImmutableDictionary<string, string> CustomMetadata,
ImmutableArray<PolicyRuleDocumentation> RuleDocumentation,
ImmutableArray<PolicyProfileDocumentation> ProfileDocumentation);
/// <summary>
/// Documentation for a single rule.
/// </summary>
public sealed record PolicyRuleDocumentation(
string RuleName,
int Priority,
string Justification,
string ConditionDescription,
ImmutableArray<string> ActionDescriptions);
/// <summary>
/// Documentation for a profile.
/// </summary>
public sealed record PolicyProfileDocumentation(
string ProfileName,
ImmutableArray<string> MapNames,
ImmutableArray<string> EnvNames,
ImmutableArray<string> ScalarNames);
/// <summary>
/// Rule coverage metadata for tracking test coverage.
/// </summary>
public sealed record PolicyRuleCoverageMetadata(
ImmutableArray<PolicyRuleCoverageEntry> Rules,
int TotalRules,
int TotalConditions,
int TotalActions,
ImmutableDictionary<string, int> ActionTypeCounts,
ImmutableArray<PolicyCoveragePath> CoveragePaths);
/// <summary>
/// Coverage entry for a single rule.
/// </summary>
public sealed record PolicyRuleCoverageEntry(
string RuleName,
int Priority,
string ConditionHash,
int ThenActionCount,
int ElseActionCount,
bool HasElseBranch,
ImmutableArray<string> CoveragePoints);
/// <summary>
/// A coverage path through the policy (for test generation).
/// </summary>
public sealed record PolicyCoveragePath(
string PathId,
ImmutableArray<string> RuleSequence,
ImmutableArray<PolicyBranchDecision> Decisions,
string PathHash);
/// <summary>
/// A branch decision point.
/// </summary>
public sealed record PolicyBranchDecision(
string RuleName,
bool TookThenBranch,
string ConditionHash);

View File

@@ -0,0 +1,988 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Extracts comprehensive metadata from compiled policy IR documents.
/// Generates symbol tables, rule indices, documentation, coverage metadata, and deterministic hashes.
/// </summary>
internal sealed class PolicyMetadataExtractor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Extracts all metadata from a compiled policy document.
/// </summary>
public PolicyCompileMetadata Extract(PolicyIrDocument document, ImmutableArray<byte> canonicalRepresentation)
{
ArgumentNullException.ThrowIfNull(document);
var symbolTable = ExtractSymbolTable(document);
var ruleIndex = BuildRuleIndex(document);
var documentation = ExtractDocumentation(document);
var coverageMetadata = BuildCoverageMetadata(document);
var hashes = ComputeHashes(document, canonicalRepresentation);
return new PolicyCompileMetadata(
symbolTable,
ruleIndex,
documentation,
coverageMetadata,
hashes);
}
#region Symbol Table Extraction
private PolicySymbolTable ExtractSymbolTable(PolicyIrDocument document)
{
var symbols = new List<PolicySymbol>();
var variables = new List<PolicyVariableDefinition>();
var referencesByName = new Dictionary<string, List<PolicySymbolReference>>();
// Extract profile symbols
if (!document.Profiles.IsDefaultOrEmpty)
{
foreach (var profile in document.Profiles)
{
symbols.Add(new PolicySymbol(
profile.Name,
PolicySymbolKind.Profile,
"profile",
new PolicySymbolScope(null, profile.Name, true),
ImmutableArray<PolicySymbolReference>.Empty));
if (!profile.Maps.IsDefaultOrEmpty)
{
foreach (var map in profile.Maps)
{
symbols.Add(new PolicySymbol(
map.Name,
PolicySymbolKind.ProfileMap,
"map",
new PolicySymbolScope(null, profile.Name, false),
ImmutableArray<PolicySymbolReference>.Empty));
}
}
if (!profile.Environments.IsDefaultOrEmpty)
{
foreach (var env in profile.Environments)
{
symbols.Add(new PolicySymbol(
env.Name,
PolicySymbolKind.ProfileEnv,
"env",
new PolicySymbolScope(null, profile.Name, false),
ImmutableArray<PolicySymbolReference>.Empty));
// Extract identifiers from environment conditions
if (!env.Entries.IsDefaultOrEmpty)
{
foreach (var entry in env.Entries)
{
ExtractExpressionReferences(entry.Condition, null, profile.Name, referencesByName);
}
}
}
}
if (!profile.Scalars.IsDefaultOrEmpty)
{
foreach (var scalar in profile.Scalars)
{
symbols.Add(new PolicySymbol(
scalar.Name,
PolicySymbolKind.ProfileScalar,
InferLiteralType(scalar.Value),
new PolicySymbolScope(null, profile.Name, false),
ImmutableArray<PolicySymbolReference>.Empty));
}
}
}
}
// Extract rule symbols and variable definitions
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
symbols.Add(new PolicySymbol(
rule.Name,
PolicySymbolKind.Rule,
"rule",
new PolicySymbolScope(rule.Name, null, true),
ImmutableArray<PolicySymbolReference>.Empty));
// Extract identifiers from rule condition
ExtractExpressionReferences(rule.When, rule.Name, null, referencesByName);
// Extract from then actions
if (!rule.ThenActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ThenActions)
{
ExtractActionReferences(action, rule.Name, referencesByName, variables);
}
}
// Extract from else actions
if (!rule.ElseActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ElseActions)
{
ExtractActionReferences(action, rule.Name, referencesByName, variables);
}
}
}
}
// Extract metadata symbols
foreach (var (key, _) in document.Metadata)
{
symbols.Add(new PolicySymbol(
key,
PolicySymbolKind.Metadata,
"metadata",
new PolicySymbolScope(null, null, true),
ImmutableArray<PolicySymbolReference>.Empty));
}
// Extract settings symbols
foreach (var (key, _) in document.Settings)
{
symbols.Add(new PolicySymbol(
key,
PolicySymbolKind.Setting,
"setting",
new PolicySymbolScope(null, null, true),
ImmutableArray<PolicySymbolReference>.Empty));
}
return new PolicySymbolTable(
symbols.ToImmutableArray(),
GetBuiltInFunctions(),
variables.ToImmutableArray(),
referencesByName.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToImmutableArray()));
}
private void ExtractExpressionReferences(
PolicyExpression? expression,
string? ruleName,
string? profileName,
Dictionary<string, List<PolicySymbolReference>> referencesByName)
{
if (expression is null) return;
switch (expression)
{
case PolicyIdentifierExpression identifier:
AddReference(referencesByName, identifier.Name, ruleName, profileName, PolicySymbolUsage.Read);
break;
case PolicyMemberAccessExpression member:
ExtractExpressionReferences(member.Target, ruleName, profileName, referencesByName);
// Member name is not a standalone identifier
break;
case PolicyInvocationExpression invocation:
ExtractExpressionReferences(invocation.Target, ruleName, profileName, referencesByName);
if (!invocation.Arguments.IsDefaultOrEmpty)
{
foreach (var arg in invocation.Arguments)
{
ExtractExpressionReferences(arg, ruleName, profileName, referencesByName);
}
}
break;
case PolicyIndexerExpression indexer:
ExtractExpressionReferences(indexer.Target, ruleName, profileName, referencesByName);
ExtractExpressionReferences(indexer.Index, ruleName, profileName, referencesByName);
break;
case PolicyUnaryExpression unary:
ExtractExpressionReferences(unary.Operand, ruleName, profileName, referencesByName);
break;
case PolicyBinaryExpression binary:
ExtractExpressionReferences(binary.Left, ruleName, profileName, referencesByName);
ExtractExpressionReferences(binary.Right, ruleName, profileName, referencesByName);
break;
case PolicyListExpression list when !list.Items.IsDefaultOrEmpty:
foreach (var item in list.Items)
{
ExtractExpressionReferences(item, ruleName, profileName, referencesByName);
}
break;
}
}
private void ExtractActionReferences(
PolicyIrAction action,
string ruleName,
Dictionary<string, List<PolicySymbolReference>> referencesByName,
List<PolicyVariableDefinition> variables)
{
switch (action)
{
case PolicyIrAssignmentAction assignment:
if (!assignment.Target.IsDefaultOrEmpty)
{
var varName = string.Join(".", assignment.Target);
AddReference(referencesByName, varName, ruleName, null, PolicySymbolUsage.Write);
variables.Add(new PolicyVariableDefinition(
varName,
InferExpressionType(assignment.Value),
SummarizeExpression(assignment.Value),
ruleName,
true));
}
ExtractExpressionReferences(assignment.Value, ruleName, null, referencesByName);
break;
case PolicyIrAnnotateAction annotate:
if (!annotate.Target.IsDefaultOrEmpty)
{
var targetName = string.Join(".", annotate.Target);
AddReference(referencesByName, targetName, ruleName, null, PolicySymbolUsage.Write);
}
ExtractExpressionReferences(annotate.Value, ruleName, null, referencesByName);
break;
case PolicyIrIgnoreAction ignore:
ExtractExpressionReferences(ignore.Until, ruleName, null, referencesByName);
break;
case PolicyIrEscalateAction escalate:
ExtractExpressionReferences(escalate.To, ruleName, null, referencesByName);
ExtractExpressionReferences(escalate.When, ruleName, null, referencesByName);
break;
case PolicyIrRequireVexAction require:
foreach (var condition in require.Conditions.Values)
{
ExtractExpressionReferences(condition, ruleName, null, referencesByName);
}
break;
case PolicyIrWarnAction warn:
ExtractExpressionReferences(warn.Message, ruleName, null, referencesByName);
break;
case PolicyIrDeferAction defer:
ExtractExpressionReferences(defer.Until, ruleName, null, referencesByName);
break;
}
}
private static void AddReference(
Dictionary<string, List<PolicySymbolReference>> referencesByName,
string symbolName,
string? ruleName,
string? profileName,
PolicySymbolUsage usage)
{
if (!referencesByName.TryGetValue(symbolName, out var refs))
{
refs = [];
referencesByName[symbolName] = refs;
}
refs.Add(new PolicySymbolReference(
symbolName,
ruleName ?? profileName ?? "global",
null,
null,
usage));
}
private static string? InferLiteralType(PolicyIrLiteral literal) => literal switch
{
PolicyIrStringLiteral => "string",
PolicyIrNumberLiteral => "number",
PolicyIrBooleanLiteral => "boolean",
PolicyIrListLiteral => "list",
_ => null
};
private static string? InferExpressionType(PolicyExpression? expression) => expression switch
{
PolicyLiteralExpression lit => lit.Value switch
{
string => "string",
decimal or double or float or int or long => "number",
bool => "boolean",
null => "null",
_ => "unknown"
},
PolicyListExpression => "list",
PolicyBinaryExpression bin => bin.Operator switch
{
PolicyBinaryOperator.And or PolicyBinaryOperator.Or or PolicyBinaryOperator.Equal or
PolicyBinaryOperator.NotEqual or PolicyBinaryOperator.LessThan or PolicyBinaryOperator.LessThanOrEqual or
PolicyBinaryOperator.GreaterThan or PolicyBinaryOperator.GreaterThanOrEqual or
PolicyBinaryOperator.In or PolicyBinaryOperator.NotIn => "boolean",
_ => "unknown"
},
PolicyUnaryExpression { Operator: PolicyUnaryOperator.Not } => "boolean",
_ => null
};
private static ImmutableArray<PolicyFunctionSignature> GetBuiltInFunctions()
{
return
[
new PolicyFunctionSignature(
"contains",
"Checks if a string contains a substring or a list contains an element",
[
new PolicyParameterInfo("haystack", "string|list", false, null, "The string or list to search in"),
new PolicyParameterInfo("needle", "any", false, null, "The value to search for")
],
"boolean",
["contains(advisory.id, \"CVE\")", "contains(tags, \"critical\")"]),
new PolicyFunctionSignature(
"startsWith",
"Checks if a string starts with a prefix",
[
new PolicyParameterInfo("value", "string", false, null, "The string to check"),
new PolicyParameterInfo("prefix", "string", false, null, "The prefix to match")
],
"boolean",
["startsWith(component.purl, \"pkg:npm\")"]),
new PolicyFunctionSignature(
"endsWith",
"Checks if a string ends with a suffix",
[
new PolicyParameterInfo("value", "string", false, null, "The string to check"),
new PolicyParameterInfo("suffix", "string", false, null, "The suffix to match")
],
"boolean",
["endsWith(component.name, \"-dev\")"]),
new PolicyFunctionSignature(
"matches",
"Checks if a string matches a regex pattern",
[
new PolicyParameterInfo("value", "string", false, null, "The string to check"),
new PolicyParameterInfo("pattern", "string", false, null, "The regex pattern")
],
"boolean",
["matches(advisory.id, \"^CVE-202[3-9]\")"]),
new PolicyFunctionSignature(
"length",
"Returns the length of a string or list",
[
new PolicyParameterInfo("value", "string|list", false, null, "The value to measure")
],
"number",
["length(component.name)", "length(tags)"]),
new PolicyFunctionSignature(
"lower",
"Converts a string to lowercase",
[
new PolicyParameterInfo("value", "string", false, null, "The string to convert")
],
"string",
["lower(component.ecosystem)"]),
new PolicyFunctionSignature(
"upper",
"Converts a string to uppercase",
[
new PolicyParameterInfo("value", "string", false, null, "The string to convert")
],
"string",
["upper(severity)"]),
new PolicyFunctionSignature(
"now",
"Returns the current evaluation timestamp (deterministic within a run)",
[],
"datetime",
["now()"]),
new PolicyFunctionSignature(
"days",
"Creates a duration in days",
[
new PolicyParameterInfo("count", "number", false, null, "Number of days")
],
"duration",
["days(30)", "days(7)"]),
new PolicyFunctionSignature(
"semver",
"Parses a semantic version string",
[
new PolicyParameterInfo("version", "string", false, null, "The version string to parse")
],
"semver",
["semver(component.version)"]),
new PolicyFunctionSignature(
"semverCompare",
"Compares two semantic versions",
[
new PolicyParameterInfo("left", "string|semver", false, null, "First version"),
new PolicyParameterInfo("right", "string|semver", false, null, "Second version")
],
"number",
["semverCompare(component.version, \"1.0.0\")"])
];
}
#endregion
#region Rule Index Building
private PolicyRuleIndex BuildRuleIndex(PolicyIrDocument document)
{
var rules = new List<PolicyRuleEntry>();
var byName = new Dictionary<string, PolicyRuleEntry>(StringComparer.Ordinal);
var byPriority = new Dictionary<int, List<PolicyRuleEntry>>();
var allActionTypes = new HashSet<string>();
var allIdentifiers = new HashSet<string>();
if (!document.Rules.IsDefaultOrEmpty)
{
for (var i = 0; i < document.Rules.Length; i++)
{
var rule = document.Rules[i];
var thenActionTypes = GetActionTypes(rule.ThenActions, allActionTypes);
var elseActionTypes = GetActionTypes(rule.ElseActions, allActionTypes);
var (identifiers, functions) = ExtractRuleReferences(rule);
foreach (var id in identifiers)
{
allIdentifiers.Add(id);
}
var entry = new PolicyRuleEntry(
rule.Name,
rule.Priority,
i,
SummarizeExpression(rule.When) ?? "true",
thenActionTypes,
elseActionTypes,
rule.Because,
identifiers,
functions);
rules.Add(entry);
byName[rule.Name] = entry;
if (!byPriority.TryGetValue(rule.Priority, out var priorityList))
{
priorityList = [];
byPriority[rule.Priority] = priorityList;
}
priorityList.Add(entry);
}
}
return new PolicyRuleIndex(
rules.ToImmutableArray(),
byName.ToImmutableDictionary(),
byPriority.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()),
allActionTypes.Order().ToImmutableArray(),
allIdentifiers.Order().ToImmutableArray());
}
private static ImmutableArray<string> GetActionTypes(
ImmutableArray<PolicyIrAction> actions,
HashSet<string> allActionTypes)
{
if (actions.IsDefaultOrEmpty) return [];
var types = new List<string>();
foreach (var action in actions)
{
var typeName = action switch
{
PolicyIrAssignmentAction => "assign",
PolicyIrAnnotateAction => "annotate",
PolicyIrIgnoreAction => "ignore",
PolicyIrEscalateAction => "escalate",
PolicyIrRequireVexAction => "requireVex",
PolicyIrWarnAction => "warn",
PolicyIrDeferAction => "defer",
_ => "unknown"
};
types.Add(typeName);
allActionTypes.Add(typeName);
}
return types.ToImmutableArray();
}
private static (ImmutableArray<string> Identifiers, ImmutableArray<string> Functions) ExtractRuleReferences(PolicyIrRule rule)
{
var identifiers = new HashSet<string>();
var functions = new HashSet<string>();
CollectExpressionReferences(rule.When, identifiers, functions);
if (!rule.ThenActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ThenActions)
{
CollectActionReferences(action, identifiers, functions);
}
}
if (!rule.ElseActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ElseActions)
{
CollectActionReferences(action, identifiers, functions);
}
}
return (identifiers.Order().ToImmutableArray(), functions.Order().ToImmutableArray());
}
private static void CollectExpressionReferences(
PolicyExpression? expression,
HashSet<string> identifiers,
HashSet<string> functions)
{
if (expression is null) return;
switch (expression)
{
case PolicyIdentifierExpression id:
identifiers.Add(id.Name);
break;
case PolicyMemberAccessExpression member:
CollectExpressionReferences(member.Target, identifiers, functions);
break;
case PolicyInvocationExpression invocation:
if (invocation.Target is PolicyIdentifierExpression funcId)
{
functions.Add(funcId.Name);
}
else
{
CollectExpressionReferences(invocation.Target, identifiers, functions);
}
if (!invocation.Arguments.IsDefaultOrEmpty)
{
foreach (var arg in invocation.Arguments)
{
CollectExpressionReferences(arg, identifiers, functions);
}
}
break;
case PolicyIndexerExpression indexer:
CollectExpressionReferences(indexer.Target, identifiers, functions);
CollectExpressionReferences(indexer.Index, identifiers, functions);
break;
case PolicyUnaryExpression unary:
CollectExpressionReferences(unary.Operand, identifiers, functions);
break;
case PolicyBinaryExpression binary:
CollectExpressionReferences(binary.Left, identifiers, functions);
CollectExpressionReferences(binary.Right, identifiers, functions);
break;
case PolicyListExpression list when !list.Items.IsDefaultOrEmpty:
foreach (var item in list.Items)
{
CollectExpressionReferences(item, identifiers, functions);
}
break;
}
}
private static void CollectActionReferences(
PolicyIrAction action,
HashSet<string> identifiers,
HashSet<string> functions)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
CollectExpressionReferences(assign.Value, identifiers, functions);
break;
case PolicyIrAnnotateAction annotate:
CollectExpressionReferences(annotate.Value, identifiers, functions);
break;
case PolicyIrIgnoreAction ignore:
CollectExpressionReferences(ignore.Until, identifiers, functions);
break;
case PolicyIrEscalateAction escalate:
CollectExpressionReferences(escalate.To, identifiers, functions);
CollectExpressionReferences(escalate.When, identifiers, functions);
break;
case PolicyIrRequireVexAction require:
foreach (var condition in require.Conditions.Values)
{
CollectExpressionReferences(condition, identifiers, functions);
}
break;
case PolicyIrWarnAction warn:
CollectExpressionReferences(warn.Message, identifiers, functions);
break;
case PolicyIrDeferAction defer:
CollectExpressionReferences(defer.Until, identifiers, functions);
break;
}
}
#endregion
#region Documentation Extraction
private PolicyDocumentation ExtractDocumentation(PolicyIrDocument document)
{
string? description = null;
var tags = ImmutableArray<string>.Empty;
string? author = null;
var customMetadata = new Dictionary<string, string>();
// Extract from metadata
if (document.Metadata.TryGetValue("description", out var descLit) && descLit is PolicyIrStringLiteral descStr)
{
description = descStr.Value;
}
if (document.Metadata.TryGetValue("author", out var authorLit) && authorLit is PolicyIrStringLiteral authorStr)
{
author = authorStr.Value;
}
if (document.Metadata.TryGetValue("tags", out var tagsLit) && tagsLit is PolicyIrListLiteral tagsList)
{
tags = tagsList.Items
.OfType<PolicyIrStringLiteral>()
.Select(s => s.Value)
.ToImmutableArray();
}
foreach (var (key, value) in document.Metadata)
{
if (key is not ("description" or "author" or "tags") && value is PolicyIrStringLiteral strVal)
{
customMetadata[key] = strVal.Value;
}
}
// Extract rule documentation
var ruleDocs = new List<PolicyRuleDocumentation>();
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
var actionDescs = new List<string>();
if (!rule.ThenActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ThenActions)
{
actionDescs.Add($"then: {DescribeAction(action)}");
}
}
if (!rule.ElseActions.IsDefaultOrEmpty)
{
foreach (var action in rule.ElseActions)
{
actionDescs.Add($"else: {DescribeAction(action)}");
}
}
ruleDocs.Add(new PolicyRuleDocumentation(
rule.Name,
rule.Priority,
rule.Because,
SummarizeExpression(rule.When) ?? "true",
actionDescs.ToImmutableArray()));
}
}
// Extract profile documentation
var profileDocs = new List<PolicyProfileDocumentation>();
if (!document.Profiles.IsDefaultOrEmpty)
{
foreach (var profile in document.Profiles)
{
profileDocs.Add(new PolicyProfileDocumentation(
profile.Name,
profile.Maps.IsDefaultOrEmpty
? []
: profile.Maps.Select(m => m.Name).ToImmutableArray(),
profile.Environments.IsDefaultOrEmpty
? []
: profile.Environments.Select(e => e.Name).ToImmutableArray(),
profile.Scalars.IsDefaultOrEmpty
? []
: profile.Scalars.Select(s => s.Name).ToImmutableArray()));
}
}
return new PolicyDocumentation(
description,
tags,
author,
customMetadata.ToImmutableDictionary(),
ruleDocs.ToImmutableArray(),
profileDocs.ToImmutableArray());
}
private static string DescribeAction(PolicyIrAction action) => action switch
{
PolicyIrAssignmentAction a => $"assign {string.Join(".", a.Target)} = {SummarizeExpression(a.Value)}",
PolicyIrAnnotateAction a => $"annotate {string.Join(".", a.Target)} = {SummarizeExpression(a.Value)}",
PolicyIrIgnoreAction a => $"ignore{(a.Until is not null ? $" until {SummarizeExpression(a.Until)}" : "")}{(a.Because is not null ? $" because \"{a.Because}\"" : "")}",
PolicyIrEscalateAction a => $"escalate{(a.To is not null ? $" to {SummarizeExpression(a.To)}" : "")}{(a.When is not null ? $" when {SummarizeExpression(a.When)}" : "")}",
PolicyIrRequireVexAction a => $"requireVex({string.Join(", ", a.Conditions.Keys)})",
PolicyIrWarnAction a => $"warn {SummarizeExpression(a.Message)}",
PolicyIrDeferAction a => $"defer{(a.Until is not null ? $" until {SummarizeExpression(a.Until)}" : "")}",
_ => "unknown"
};
#endregion
#region Coverage Metadata Building
private PolicyRuleCoverageMetadata BuildCoverageMetadata(PolicyIrDocument document)
{
var rules = new List<PolicyRuleCoverageEntry>();
var actionTypeCounts = new Dictionary<string, int>();
var totalConditions = 0;
var totalActions = 0;
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
totalConditions++;
var thenCount = rule.ThenActions.IsDefaultOrEmpty ? 0 : rule.ThenActions.Length;
var elseCount = rule.ElseActions.IsDefaultOrEmpty ? 0 : rule.ElseActions.Length;
totalActions += thenCount + elseCount;
// Count action types
CountActionTypes(rule.ThenActions, actionTypeCounts);
CountActionTypes(rule.ElseActions, actionTypeCounts);
// Generate coverage points
var coveragePoints = new List<string>
{
$"{rule.Name}:condition"
};
if (thenCount > 0)
{
coveragePoints.Add($"{rule.Name}:then");
for (var i = 0; i < thenCount; i++)
{
coveragePoints.Add($"{rule.Name}:then[{i}]");
}
}
if (elseCount > 0)
{
coveragePoints.Add($"{rule.Name}:else");
for (var i = 0; i < elseCount; i++)
{
coveragePoints.Add($"{rule.Name}:else[{i}]");
}
}
rules.Add(new PolicyRuleCoverageEntry(
rule.Name,
rule.Priority,
ComputeExpressionHash(rule.When),
thenCount,
elseCount,
elseCount > 0,
coveragePoints.ToImmutableArray()));
}
}
// Generate coverage paths (simplified - exhaustive paths for small policies)
var coveragePaths = GenerateCoveragePaths(document.Rules);
return new PolicyRuleCoverageMetadata(
rules.ToImmutableArray(),
rules.Count,
totalConditions,
totalActions,
actionTypeCounts.ToImmutableDictionary(),
coveragePaths);
}
private static void CountActionTypes(ImmutableArray<PolicyIrAction> actions, Dictionary<string, int> counts)
{
if (actions.IsDefaultOrEmpty) return;
foreach (var action in actions)
{
var typeName = action switch
{
PolicyIrAssignmentAction => "assign",
PolicyIrAnnotateAction => "annotate",
PolicyIrIgnoreAction => "ignore",
PolicyIrEscalateAction => "escalate",
PolicyIrRequireVexAction => "requireVex",
PolicyIrWarnAction => "warn",
PolicyIrDeferAction => "defer",
_ => "unknown"
};
counts.TryGetValue(typeName, out var count);
counts[typeName] = count + 1;
}
}
private static ImmutableArray<PolicyCoveragePath> GenerateCoveragePaths(ImmutableArray<PolicyIrRule> rules)
{
if (rules.IsDefaultOrEmpty) return [];
var paths = new List<PolicyCoveragePath>();
// For small policies, generate all 2^n paths
// For larger policies, generate key paths only
var ruleCount = rules.Length;
var maxPaths = ruleCount <= 10 ? (1 << ruleCount) : 100;
for (var pathIndex = 0; pathIndex < maxPaths && pathIndex < (1 << ruleCount); pathIndex++)
{
var sequence = new List<string>();
var decisions = new List<PolicyBranchDecision>();
var pathHashBuilder = new StringBuilder();
for (var ruleIndex = 0; ruleIndex < ruleCount; ruleIndex++)
{
var rule = rules[ruleIndex];
var tookThen = (pathIndex & (1 << ruleIndex)) != 0;
sequence.Add(rule.Name);
decisions.Add(new PolicyBranchDecision(
rule.Name,
tookThen,
ComputeExpressionHash(rule.When)));
pathHashBuilder.Append(rule.Name);
pathHashBuilder.Append(tookThen ? ":T" : ":F");
pathHashBuilder.Append('|');
}
var pathId = $"path_{pathIndex:D4}";
var pathHash = ComputeStringHash(pathHashBuilder.ToString());
paths.Add(new PolicyCoveragePath(
pathId,
sequence.ToImmutableArray(),
decisions.ToImmutableArray(),
pathHash));
}
return paths.ToImmutableArray();
}
#endregion
#region Hash Computation
private PolicyDeterministicHashes ComputeHashes(PolicyIrDocument document, ImmutableArray<byte> canonicalRepresentation)
{
// Content hash from canonical representation
var contentHash = ComputeHash(canonicalRepresentation.AsSpan());
// Structure hash (rules only)
var structureBuilder = new StringBuilder();
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
structureBuilder.Append(rule.Name);
structureBuilder.Append(':');
structureBuilder.Append(rule.Priority);
structureBuilder.Append(':');
structureBuilder.Append(ComputeExpressionHash(rule.When));
structureBuilder.Append('|');
}
}
var structureHash = ComputeStringHash(structureBuilder.ToString());
// Ordering hash (names and priorities only)
var orderingBuilder = new StringBuilder();
if (!document.Rules.IsDefaultOrEmpty)
{
foreach (var rule in document.Rules)
{
orderingBuilder.Append(rule.Name);
orderingBuilder.Append(':');
orderingBuilder.Append(rule.Priority);
orderingBuilder.Append('|');
}
}
var orderingHash = ComputeStringHash(orderingBuilder.ToString());
// Identity hash (combination)
var identityBuilder = new StringBuilder();
identityBuilder.Append(document.Name);
identityBuilder.Append(':');
identityBuilder.Append(document.Syntax);
identityBuilder.Append(':');
identityBuilder.Append(contentHash);
var identityHash = ComputeStringHash(identityBuilder.ToString());
return new PolicyDeterministicHashes(contentHash, structureHash, orderingHash, identityHash);
}
private static string ComputeExpressionHash(PolicyExpression? expression)
{
if (expression is null) return "null";
var summary = SummarizeExpression(expression) ?? "empty";
return ComputeStringHash(summary);
}
private static string ComputeStringHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return ComputeHash(bytes);
}
private static string ComputeHash(ReadOnlySpan<byte> bytes)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return Convert.ToHexStringLower(hash);
}
private static string? SummarizeExpression(PolicyExpression? expression, int maxLength = 100)
{
if (expression is null) return null;
var summary = expression switch
{
PolicyLiteralExpression lit => lit.Value?.ToString() ?? "null",
PolicyIdentifierExpression id => id.Name,
PolicyMemberAccessExpression member => $"{SummarizeExpression(member.Target)}.{member.Member}",
PolicyInvocationExpression inv => $"{SummarizeExpression(inv.Target)}({string.Join(", ", inv.Arguments.IsDefaultOrEmpty ? [] : inv.Arguments.Select(a => SummarizeExpression(a)))})",
PolicyIndexerExpression idx => $"{SummarizeExpression(idx.Target)}[{SummarizeExpression(idx.Index)}]",
PolicyUnaryExpression unary => $"{unary.Operator} {SummarizeExpression(unary.Operand)}",
PolicyBinaryExpression binary => $"{SummarizeExpression(binary.Left)} {binary.Operator} {SummarizeExpression(binary.Right)}",
PolicyListExpression list => $"[{string.Join(", ", list.Items.IsDefaultOrEmpty ? [] : list.Items.Take(3).Select(i => SummarizeExpression(i)))}{(list.Items.Length > 3 ? ", ..." : "")}]",
_ => expression.GetType().Name
};
return summary.Length > maxLength ? summary[..(maxLength - 3)] + "..." : summary;
}
#endregion
}

View File

@@ -0,0 +1,154 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.WhatIfSimulation;
using StellaOps.Policy.Engine.Workers;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.DependencyInjection;
/// <summary>
/// Extension methods for registering Policy Engine services.
/// </summary>
public static class PolicyEngineServiceCollectionExtensions
{
/// <summary>
/// Adds the core Policy Engine services to the service collection.
/// Includes TimeProvider, cache, and core evaluation services.
/// </summary>
public static IServiceCollection AddPolicyEngineCore(this IServiceCollection services)
{
// Time provider
services.TryAddSingleton(TimeProvider.System);
// Core compilation and evaluation services
services.TryAddSingleton<PolicyCompilationService>();
// Cache
services.TryAddSingleton<IPolicyEvaluationCache, InMemoryPolicyEvaluationCache>();
// Runtime evaluation
services.TryAddSingleton<PolicyRuntimeEvaluationService>();
// Bundle service
services.TryAddSingleton<PolicyBundleService>();
// Decision service
services.TryAddSingleton<PolicyDecisionService>();
return services;
}
/// <summary>
/// Adds the Policy Engine event pipeline services.
/// Includes event processor and job scheduler.
/// </summary>
public static IServiceCollection AddPolicyEngineEventPipeline(this IServiceCollection services)
{
// Event processor (implements both IPolicyEffectiveEventPublisher and IReEvaluationJobScheduler)
services.TryAddSingleton<PolicyEventProcessor>();
services.TryAddSingleton<IPolicyEffectiveEventPublisher>(sp =>
sp.GetRequiredService<PolicyEventProcessor>());
services.TryAddSingleton<IReEvaluationJobScheduler>(sp =>
sp.GetRequiredService<PolicyEventProcessor>());
return services;
}
/// <summary>
/// Adds the Policy Engine evaluation worker services.
/// Includes background host for continuous job processing.
/// </summary>
public static IServiceCollection AddPolicyEngineWorker(this IServiceCollection services)
{
// Worker service
services.TryAddSingleton<PolicyEvaluationWorkerService>();
// Background host
services.AddHostedService<PolicyEvaluationWorkerHost>();
return services;
}
/// <summary>
/// Adds the Policy Engine explainer services.
/// Requires IExplainTraceRepository and IPolicyPackRepository to be registered.
/// </summary>
public static IServiceCollection AddPolicyEngineExplainer(this IServiceCollection services)
{
services.TryAddSingleton<PolicyExplainerService>();
return services;
}
/// <summary>
/// Adds the effective decision map services for Graph overlays.
/// Requires Redis connection to be registered.
/// </summary>
public static IServiceCollection AddEffectiveDecisionMap(this IServiceCollection services)
{
services.TryAddSingleton<IEffectiveDecisionMap, RedisEffectiveDecisionMap>();
return services;
}
/// <summary>
/// Adds the exception effective cache for fast exception lookups during policy evaluation.
/// Requires Redis connection and IExceptionRepository to be registered.
/// </summary>
public static IServiceCollection AddExceptionEffectiveCache(this IServiceCollection services)
{
services.TryAddSingleton<IExceptionEffectiveCache, RedisExceptionEffectiveCache>();
return services;
}
/// <summary>
/// Adds the What-If simulation service for Graph APIs.
/// Supports hypothetical SBOM diffs and draft policies without persisting results.
/// </summary>
public static IServiceCollection AddWhatIfSimulation(this IServiceCollection services)
{
services.TryAddSingleton<WhatIfSimulationService>();
return services;
}
/// <summary>
/// Adds Redis connection for effective decision map and evaluation cache.
/// </summary>
public static IServiceCollection AddPolicyEngineRedis(
this IServiceCollection services,
string connectionString)
{
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(connectionString));
return services;
}
/// <summary>
/// Adds all Policy Engine services with default configuration.
/// </summary>
public static IServiceCollection AddPolicyEngine(this IServiceCollection services)
{
services.AddPolicyEngineCore();
services.AddPolicyEngineEventPipeline();
services.AddPolicyEngineWorker();
services.AddPolicyEngineExplainer();
return services;
}
/// <summary>
/// Adds all Policy Engine services with configuration binding.
/// </summary>
public static IServiceCollection AddPolicyEngine(
this IServiceCollection services,
Action<PolicyEngineOptions> configure)
{
services.Configure(configure);
return services.AddPolicyEngine();
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Domain;
@@ -113,6 +114,7 @@ internal sealed record PolicyBundleRecord(
int Size,
DateTimeOffset CreatedAt,
ImmutableArray<byte> Payload,
PolicyIrDocument? CompiledDocument = null,
PolicyAocMetadata? AocMetadata = null);
/// <summary>

View File

@@ -0,0 +1,221 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Represents an effective policy decision for an asset/snapshot.
/// Stored in Redis for Graph overlay lookups.
/// </summary>
public sealed record EffectiveDecisionEntry
{
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Asset identifier (PURL or SBOM ID).
/// </summary>
[JsonPropertyName("asset_id")]
public required string AssetId { get; init; }
/// <summary>
/// Snapshot identifier (SBOM version or evaluation run).
/// </summary>
[JsonPropertyName("snapshot_id")]
public required string SnapshotId { get; init; }
/// <summary>
/// Policy pack ID that produced this decision.
/// </summary>
[JsonPropertyName("pack_id")]
public required string PackId { get; init; }
/// <summary>
/// Policy pack version.
/// </summary>
[JsonPropertyName("pack_version")]
public required int PackVersion { get; init; }
/// <summary>
/// Final decision status (allow, warn, deny, blocked).
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// Severity level if applicable.
/// </summary>
[JsonPropertyName("severity")]
public string? Severity { get; init; }
/// <summary>
/// Rule name that determined the decision.
/// </summary>
[JsonPropertyName("rule_name")]
public string? RuleName { get; init; }
/// <summary>
/// Priority of the applied rule.
/// </summary>
[JsonPropertyName("priority")]
public int? Priority { get; init; }
/// <summary>
/// Exception ID if an exception was applied.
/// </summary>
[JsonPropertyName("exception_id")]
public string? ExceptionId { get; init; }
/// <summary>
/// Count of advisories affecting this asset.
/// </summary>
[JsonPropertyName("advisory_count")]
public int AdvisoryCount { get; init; }
/// <summary>
/// Count of critical/high severity findings.
/// </summary>
[JsonPropertyName("high_severity_count")]
public int HighSeverityCount { get; init; }
/// <summary>
/// Aggregated annotations from the decision.
/// </summary>
[JsonPropertyName("annotations")]
public ImmutableDictionary<string, string> Annotations { get; init; } = ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Version counter for cache coherency.
/// </summary>
[JsonPropertyName("version")]
public required long Version { get; init; }
/// <summary>
/// When this entry was evaluated.
/// </summary>
[JsonPropertyName("evaluated_at")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// When this entry expires.
/// </summary>
[JsonPropertyName("expires_at")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Result of an effective decision map query.
/// </summary>
public sealed record EffectiveDecisionQueryResult
{
/// <summary>
/// Found entries mapped by asset ID.
/// </summary>
public required IReadOnlyDictionary<string, EffectiveDecisionEntry> Entries { get; init; }
/// <summary>
/// Asset IDs that were not found.
/// </summary>
public required IReadOnlyList<string> NotFound { get; init; }
/// <summary>
/// Current version of the decision map.
/// </summary>
public long MapVersion { get; init; }
/// <summary>
/// Whether the result came from cache.
/// </summary>
public bool FromCache { get; init; }
}
/// <summary>
/// Summary statistics for a snapshot's effective decisions.
/// </summary>
public sealed record EffectiveDecisionSummary
{
/// <summary>
/// Snapshot ID.
/// </summary>
public required string SnapshotId { get; init; }
/// <summary>
/// Total assets evaluated.
/// </summary>
public int TotalAssets { get; init; }
/// <summary>
/// Count by status.
/// </summary>
public required IReadOnlyDictionary<string, int> StatusCounts { get; init; }
/// <summary>
/// Count by severity.
/// </summary>
public required IReadOnlyDictionary<string, int> SeverityCounts { get; init; }
/// <summary>
/// Assets with exceptions applied.
/// </summary>
public int ExceptionCount { get; init; }
/// <summary>
/// Map version at time of summary.
/// </summary>
public long MapVersion { get; init; }
/// <summary>
/// When this summary was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Filter options for querying effective decisions.
/// </summary>
public sealed record EffectiveDecisionFilter
{
/// <summary>
/// Filter by status values.
/// </summary>
public IReadOnlyList<string>? Statuses { get; init; }
/// <summary>
/// Filter by severity values.
/// </summary>
public IReadOnlyList<string>? Severities { get; init; }
/// <summary>
/// Include only assets with exceptions.
/// </summary>
public bool? HasException { get; init; }
/// <summary>
/// Filter by minimum advisory count.
/// </summary>
public int? MinAdvisoryCount { get; init; }
/// <summary>
/// Filter by minimum high severity count.
/// </summary>
public int? MinHighSeverityCount { get; init; }
/// <summary>
/// Maximum results to return.
/// </summary>
public int Limit { get; init; } = 1000;
/// <summary>
/// Offset for pagination.
/// </summary>
public int Offset { get; init; } = 0;
}

View File

@@ -0,0 +1,144 @@
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Interface for effective decision map storage.
/// Maintains policy decisions per asset/snapshot for Graph overlays.
/// </summary>
public interface IEffectiveDecisionMap
{
/// <summary>
/// Sets an effective decision entry.
/// </summary>
Task SetAsync(
string tenantId,
string snapshotId,
EffectiveDecisionEntry entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets multiple effective decision entries.
/// </summary>
Task SetBatchAsync(
string tenantId,
string snapshotId,
IEnumerable<EffectiveDecisionEntry> entries,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an effective decision entry.
/// </summary>
Task<EffectiveDecisionEntry?> GetAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple effective decision entries.
/// </summary>
Task<EffectiveDecisionQueryResult> GetBatchAsync(
string tenantId,
string snapshotId,
IReadOnlyList<string> assetIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all effective decisions for a snapshot.
/// </summary>
Task<IReadOnlyList<EffectiveDecisionEntry>> GetAllForSnapshotAsync(
string tenantId,
string snapshotId,
EffectiveDecisionFilter? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a summary of effective decisions for a snapshot.
/// </summary>
Task<EffectiveDecisionSummary> GetSummaryAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates a specific entry.
/// </summary>
Task InvalidateAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all entries for a snapshot.
/// </summary>
Task InvalidateSnapshotAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all entries for a tenant.
/// </summary>
Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current map version for a snapshot.
/// </summary>
Task<long> GetVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments and returns the new map version for a snapshot.
/// </summary>
Task<long> IncrementVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets statistics about the effective decision map.
/// </summary>
Task<EffectiveDecisionMapStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Statistics about the effective decision map.
/// </summary>
public sealed record EffectiveDecisionMapStats
{
/// <summary>
/// Total entries across all tenants/snapshots.
/// </summary>
public long TotalEntries { get; init; }
/// <summary>
/// Total snapshots tracked.
/// </summary>
public long TotalSnapshots { get; init; }
/// <summary>
/// Memory used in bytes (if available).
/// </summary>
public long? MemoryUsedBytes { get; init; }
/// <summary>
/// Entries expiring in the next hour.
/// </summary>
public long ExpiringWithinHour { get; init; }
/// <summary>
/// Last eviction timestamp.
/// </summary>
public DateTimeOffset? LastEvictionAt { get; init; }
/// <summary>
/// Count of entries evicted in last eviction run.
/// </summary>
public long LastEvictionCount { get; init; }
}

View File

@@ -0,0 +1,501 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Redis-backed effective decision map with versioning and TTL-based eviction.
/// Key structure:
/// - Entry: stellaops:edm:{tenant}:{snapshot}:e:{asset} -> JSON entry
/// - Version: stellaops:edm:{tenant}:{snapshot}:v -> integer version
/// - Index: stellaops:edm:{tenant}:{snapshot}:idx -> sorted set of assets by evaluated_at
/// </summary>
internal sealed class RedisEffectiveDecisionMap : IEffectiveDecisionMap
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<RedisEffectiveDecisionMap> _logger;
private readonly EffectiveDecisionMapOptions _options;
private readonly TimeProvider _timeProvider;
private const string KeyPrefix = "stellaops:edm";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public RedisEffectiveDecisionMap(
IConnectionMultiplexer redis,
ILogger<RedisEffectiveDecisionMap> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.EffectiveDecisionMap ?? new EffectiveDecisionMapOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task SetAsync(
string tenantId,
string snapshotId,
EffectiveDecisionEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var db = _redis.GetDatabase();
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
var json = JsonSerializer.Serialize(entry, JsonOptions);
var ttl = entry.ExpiresAt - _timeProvider.GetUtcNow();
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
var tasks = new List<Task>
{
db.StringSetAsync(entryKey, json, ttl),
db.SortedSetAddAsync(indexKey, entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds()),
db.KeyExpireAsync(indexKey, ttl + TimeSpan.FromMinutes(5)), // Index lives slightly longer
};
await Task.WhenAll(tasks).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "set"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task SetBatchAsync(
string tenantId,
string snapshotId,
IEnumerable<EffectiveDecisionEntry> entries,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var batch = db.CreateBatch();
var indexKey = GetIndexKey(tenantId, snapshotId);
var now = _timeProvider.GetUtcNow();
var count = 0;
var sortedSetEntries = new List<SortedSetEntry>();
foreach (var entry in entries)
{
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var json = JsonSerializer.Serialize(entry, JsonOptions);
var ttl = entry.ExpiresAt - now;
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
_ = batch.StringSetAsync(entryKey, json, ttl);
sortedSetEntries.Add(new SortedSetEntry(entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds()));
count++;
}
if (sortedSetEntries.Count > 0)
{
_ = batch.SortedSetAddAsync(indexKey, sortedSetEntries.ToArray());
_ = batch.KeyExpireAsync(indexKey, TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 5));
}
batch.Execute();
await Task.CompletedTask; // Batch operations are synchronous
// Increment version after batch write
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count,
new KeyValuePair<string, object?>("operation", "set_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogDebug("Set {Count} effective decisions for snapshot {SnapshotId}", count, snapshotId);
}
public async Task<EffectiveDecisionEntry?> GetAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var json = await db.StringGetAsync(entryKey).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get"),
new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("cache_hit", json.HasValue));
if (!json.HasValue)
{
return null;
}
return JsonSerializer.Deserialize<EffectiveDecisionEntry>((string)json!, JsonOptions);
}
public async Task<EffectiveDecisionQueryResult> GetBatchAsync(
string tenantId,
string snapshotId,
IReadOnlyList<string> assetIds,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var keys = assetIds.Select(id => (RedisKey)GetEntryKey(tenantId, snapshotId, id)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
var entries = new Dictionary<string, EffectiveDecisionEntry>();
var notFound = new List<string>();
for (int i = 0; i < assetIds.Count; i++)
{
if (values[i].HasValue)
{
var entry = JsonSerializer.Deserialize<EffectiveDecisionEntry>((string)values[i]!, JsonOptions);
if (entry != null)
{
entries[assetIds[i]] = entry;
}
}
else
{
notFound.Add(assetIds[i]);
}
}
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Count,
new KeyValuePair<string, object?>("operation", "get_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return new EffectiveDecisionQueryResult
{
Entries = entries,
NotFound = notFound,
MapVersion = version,
FromCache = true,
};
}
public async Task<IReadOnlyList<EffectiveDecisionEntry>> GetAllForSnapshotAsync(
string tenantId,
string snapshotId,
EffectiveDecisionFilter? filter = null,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from the index
var assetIds = await db.SortedSetRangeByRankAsync(indexKey, 0, -1, Order.Descending)
.ConfigureAwait(false);
if (assetIds.Length == 0)
{
return Array.Empty<EffectiveDecisionEntry>();
}
// Get all entries
var keys = assetIds.Select(id => (RedisKey)GetEntryKey(tenantId, snapshotId, id!)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
var entries = new List<EffectiveDecisionEntry>();
foreach (var value in values)
{
if (!value.HasValue) continue;
var entry = JsonSerializer.Deserialize<EffectiveDecisionEntry>((string)value!, JsonOptions);
if (entry is null) continue;
// Apply filters
if (filter != null)
{
if (filter.Statuses?.Count > 0 &&
!filter.Statuses.Contains(entry.Status, StringComparer.OrdinalIgnoreCase))
{
continue;
}
if (filter.Severities?.Count > 0 &&
(entry.Severity is null || !filter.Severities.Contains(entry.Severity, StringComparer.OrdinalIgnoreCase)))
{
continue;
}
if (filter.HasException == true && entry.ExceptionId is null)
{
continue;
}
if (filter.HasException == false && entry.ExceptionId is not null)
{
continue;
}
if (filter.MinAdvisoryCount.HasValue && entry.AdvisoryCount < filter.MinAdvisoryCount)
{
continue;
}
if (filter.MinHighSeverityCount.HasValue && entry.HighSeverityCount < filter.MinHighSeverityCount)
{
continue;
}
}
entries.Add(entry);
// Apply limit
if (filter?.Limit > 0 && entries.Count >= filter.Limit + (filter?.Offset ?? 0))
{
break;
}
}
// Apply offset
if (filter?.Offset > 0)
{
entries = entries.Skip(filter.Offset).ToList();
}
// Apply final limit
if (filter?.Limit > 0)
{
entries = entries.Take(filter.Limit).ToList();
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get_all"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return entries;
}
public async Task<EffectiveDecisionSummary> GetSummaryAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var entries = await GetAllForSnapshotAsync(tenantId, snapshotId, null, cancellationToken)
.ConfigureAwait(false);
var statusCounts = entries
.GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var severityCounts = entries
.Where(e => e.Severity is not null)
.GroupBy(e => e.Severity!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
return new EffectiveDecisionSummary
{
SnapshotId = snapshotId,
TotalAssets = entries.Count,
StatusCounts = statusCounts,
SeverityCounts = severityCounts,
ExceptionCount = entries.Count(e => e.ExceptionId is not null),
MapVersion = version,
ComputedAt = _timeProvider.GetUtcNow(),
};
}
public async Task InvalidateAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
await Task.WhenAll(
db.KeyDeleteAsync(entryKey),
db.SortedSetRemoveAsync(indexKey, assetId)
).ConfigureAwait(false);
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "invalidate"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task InvalidateSnapshotAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from the index
var assetIds = await db.SortedSetRangeByRankAsync(indexKey).ConfigureAwait(false);
if (assetIds.Length > 0)
{
var keys = assetIds
.Select(id => (RedisKey)GetEntryKey(tenantId, snapshotId, id!))
.Append(indexKey)
.Append(GetVersionKey(tenantId, snapshotId))
.ToArray();
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Length,
new KeyValuePair<string, object?>("operation", "invalidate_snapshot"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} entries for snapshot {SnapshotId}", assetIds.Length, snapshotId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = $"{KeyPrefix}:{tenantId}:*";
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(keys.Length,
new KeyValuePair<string, object?>("operation", "invalidate_tenant"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} keys for tenant {TenantId}", keys.Length, tenantId);
}
public async Task<long> GetVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId, snapshotId);
var version = await db.StringGetAsync(versionKey).ConfigureAwait(false);
return version.HasValue ? (long)version : 0;
}
public async Task<long> IncrementVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId, snapshotId);
var newVersion = await db.StringIncrementAsync(versionKey).ConfigureAwait(false);
// Set TTL on version key if not already set
await db.KeyExpireAsync(versionKey, TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10), ExpireWhen.HasNoExpiry)
.ConfigureAwait(false);
return newVersion;
}
public async Task<EffectiveDecisionMapStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:*:e:*"
: $"{KeyPrefix}:*:e:*";
var entryCount = server.Keys(pattern: pattern).Count();
var snapshotPattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:*:idx"
: $"{KeyPrefix}:*:idx";
var snapshotCount = server.Keys(pattern: snapshotPattern).Count();
long? memoryUsed = null;
try
{
var info = server.Info("memory");
var memorySection = info.FirstOrDefault(s => s.Key == "Memory");
if (memorySection is not null)
{
var usedMemory = memorySection.FirstOrDefault(p => p.Key == "used_memory");
if (usedMemory.Key is not null && long.TryParse(usedMemory.Value, out var bytes))
{
memoryUsed = bytes;
}
}
}
catch
{
// Ignore - memory info not available
}
return new EffectiveDecisionMapStats
{
TotalEntries = entryCount,
TotalSnapshots = snapshotCount,
MemoryUsedBytes = memoryUsed,
ExpiringWithinHour = 0, // Would require scanning TTLs
LastEvictionAt = null,
LastEvictionCount = 0,
};
}
private static string GetEntryKey(string tenantId, string snapshotId, string assetId) =>
$"{KeyPrefix}:{tenantId}:{snapshotId}:e:{assetId}";
private static string GetIndexKey(string tenantId, string snapshotId) =>
$"{KeyPrefix}:{tenantId}:{snapshotId}:idx";
private static string GetVersionKey(string tenantId, string snapshotId) =>
$"{KeyPrefix}:{tenantId}:{snapshotId}:v";
}
/// <summary>
/// Configuration options for effective decision map.
/// </summary>
public sealed class EffectiveDecisionMapOptions
{
/// <summary>
/// Default TTL for entries in minutes.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 60;
/// <summary>
/// Maximum entries per snapshot.
/// </summary>
public int MaxEntriesPerSnapshot { get; set; } = 100000;
/// <summary>
/// Whether to enable automatic eviction of expired entries.
/// </summary>
public bool EnableAutoEviction { get; set; } = true;
/// <summary>
/// Eviction check interval in minutes.
/// </summary>
public int EvictionIntervalMinutes { get; set; } = 5;
}

View File

@@ -0,0 +1,184 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Events;
/// <summary>
/// Type of policy effective event.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyEffectiveEventType>))]
public enum PolicyEffectiveEventType
{
/// <summary>Policy decision changed for a subject.</summary>
[JsonPropertyName("policy.effective.updated")]
EffectiveUpdated,
/// <summary>Policy decision added for new subject.</summary>
[JsonPropertyName("policy.effective.added")]
EffectiveAdded,
/// <summary>Policy decision removed (subject no longer affected).</summary>
[JsonPropertyName("policy.effective.removed")]
EffectiveRemoved,
/// <summary>Batch re-evaluation completed.</summary>
[JsonPropertyName("policy.effective.batch_completed")]
BatchCompleted
}
/// <summary>
/// Base class for policy effective events.
/// </summary>
public abstract record PolicyEffectiveEvent(
[property: JsonPropertyName("event_id")] string EventId,
[property: JsonPropertyName("event_type")] PolicyEffectiveEventType EventType,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("correlation_id")] string? CorrelationId);
/// <summary>
/// Event emitted when a policy decision is updated for a subject.
/// </summary>
public sealed record PolicyEffectiveUpdatedEvent(
string EventId,
string TenantId,
DateTimeOffset Timestamp,
string? CorrelationId,
[property: JsonPropertyName("pack_id")] string PackId,
[property: JsonPropertyName("pack_version")] int PackVersion,
[property: JsonPropertyName("subject_purl")] string SubjectPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("trigger_type")] string TriggerType,
[property: JsonPropertyName("diff")] PolicyDecisionDiff Diff)
: PolicyEffectiveEvent(EventId, PolicyEffectiveEventType.EffectiveUpdated, TenantId, Timestamp, CorrelationId);
/// <summary>
/// Diff metadata for policy decision changes.
/// </summary>
public sealed record PolicyDecisionDiff(
[property: JsonPropertyName("old_status")] string? OldStatus,
[property: JsonPropertyName("new_status")] string NewStatus,
[property: JsonPropertyName("old_severity")] string? OldSeverity,
[property: JsonPropertyName("new_severity")] string? NewSeverity,
[property: JsonPropertyName("old_rule")] string? OldRule,
[property: JsonPropertyName("new_rule")] string? NewRule,
[property: JsonPropertyName("old_priority")] int? OldPriority,
[property: JsonPropertyName("new_priority")] int? NewPriority,
[property: JsonPropertyName("status_changed")] bool StatusChanged,
[property: JsonPropertyName("severity_changed")] bool SeverityChanged,
[property: JsonPropertyName("rule_changed")] bool RuleChanged,
[property: JsonPropertyName("annotations_added")] ImmutableArray<string> AnnotationsAdded,
[property: JsonPropertyName("annotations_removed")] ImmutableArray<string> AnnotationsRemoved)
{
/// <summary>
/// Creates a diff between two policy decisions.
/// </summary>
public static PolicyDecisionDiff Create(
string? oldStatus, string newStatus,
string? oldSeverity, string? newSeverity,
string? oldRule, string? newRule,
int? oldPriority, int? newPriority,
ImmutableDictionary<string, string>? oldAnnotations,
ImmutableDictionary<string, string>? newAnnotations)
{
var oldKeys = oldAnnotations?.Keys ?? Enumerable.Empty<string>();
var newKeys = newAnnotations?.Keys ?? Enumerable.Empty<string>();
var annotationsAdded = newKeys
.Where(k => oldAnnotations?.ContainsKey(k) != true)
.OrderBy(k => k)
.ToImmutableArray();
var annotationsRemoved = oldKeys
.Where(k => newAnnotations?.ContainsKey(k) != true)
.OrderBy(k => k)
.ToImmutableArray();
return new PolicyDecisionDiff(
OldStatus: oldStatus,
NewStatus: newStatus,
OldSeverity: oldSeverity,
NewSeverity: newSeverity,
OldRule: oldRule,
NewRule: newRule,
OldPriority: oldPriority,
NewPriority: newPriority,
StatusChanged: !string.Equals(oldStatus, newStatus, StringComparison.Ordinal),
SeverityChanged: !string.Equals(oldSeverity, newSeverity, StringComparison.Ordinal),
RuleChanged: !string.Equals(oldRule, newRule, StringComparison.Ordinal),
AnnotationsAdded: annotationsAdded,
AnnotationsRemoved: annotationsRemoved);
}
}
/// <summary>
/// Event emitted when batch re-evaluation completes.
/// </summary>
public sealed record PolicyBatchCompletedEvent(
string EventId,
string TenantId,
DateTimeOffset Timestamp,
string? CorrelationId,
[property: JsonPropertyName("batch_id")] string BatchId,
[property: JsonPropertyName("trigger_type")] string TriggerType,
[property: JsonPropertyName("subjects_evaluated")] int SubjectsEvaluated,
[property: JsonPropertyName("decisions_changed")] int DecisionsChanged,
[property: JsonPropertyName("duration_ms")] long DurationMs,
[property: JsonPropertyName("summary")] PolicyBatchSummary Summary)
: PolicyEffectiveEvent(EventId, PolicyEffectiveEventType.BatchCompleted, TenantId, Timestamp, CorrelationId);
/// <summary>
/// Summary of changes in a batch re-evaluation.
/// </summary>
public sealed record PolicyBatchSummary(
[property: JsonPropertyName("status_upgrades")] int StatusUpgrades,
[property: JsonPropertyName("status_downgrades")] int StatusDowngrades,
[property: JsonPropertyName("new_blocks")] int NewBlocks,
[property: JsonPropertyName("blocks_removed")] int BlocksRemoved,
[property: JsonPropertyName("affected_advisories")] ImmutableArray<string> AffectedAdvisories,
[property: JsonPropertyName("affected_purls")] ImmutableArray<string> AffectedPurls);
/// <summary>
/// Request to schedule a re-evaluation job.
/// </summary>
public sealed record ReEvaluationJobRequest(
string JobId,
string TenantId,
string PackId,
int PackVersion,
string TriggerType,
string? CorrelationId,
DateTimeOffset CreatedAt,
PolicyChangePriority Priority,
ImmutableArray<string> AdvisoryIds,
ImmutableArray<string> SubjectPurls,
ImmutableArray<string> SbomIds,
ImmutableDictionary<string, string> Metadata)
{
/// <summary>
/// Creates a deterministic job ID.
/// </summary>
public static string CreateJobId(
string tenantId,
string packId,
int packVersion,
string triggerType,
DateTimeOffset createdAt)
{
var seed = $"{tenantId}|{packId}|{packVersion}|{triggerType}|{createdAt:O}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"rej-{Convert.ToHexStringLower(bytes)[..16]}";
}
}
/// <summary>
/// Policy change priority from IncrementalOrchestrator namespace.
/// </summary>
public enum PolicyChangePriority
{
Normal = 0,
High = 1,
Emergency = 2
}

View File

@@ -0,0 +1,454 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.IncrementalOrchestrator;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Events;
/// <summary>
/// Interface for publishing policy effective events.
/// </summary>
public interface IPolicyEffectiveEventPublisher
{
/// <summary>
/// Publishes a policy effective updated event.
/// </summary>
Task PublishEffectiveUpdatedAsync(PolicyEffectiveUpdatedEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a batch completed event.
/// </summary>
Task PublishBatchCompletedAsync(PolicyBatchCompletedEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a handler for effective events.
/// </summary>
void RegisterHandler(Func<PolicyEffectiveEvent, Task> handler);
}
/// <summary>
/// Interface for scheduling re-evaluation jobs.
/// </summary>
public interface IReEvaluationJobScheduler
{
/// <summary>
/// Schedules a re-evaluation job.
/// </summary>
Task<string> ScheduleAsync(ReEvaluationJobRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending job count.
/// </summary>
int GetPendingJobCount();
/// <summary>
/// Gets job by ID.
/// </summary>
ReEvaluationJobRequest? GetJob(string jobId);
}
/// <summary>
/// Processes policy change events, schedules re-evaluations, and emits effective events.
/// </summary>
public sealed class PolicyEventProcessor : IPolicyEffectiveEventPublisher, IReEvaluationJobScheduler
{
private readonly ILogger<PolicyEventProcessor> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentQueue<ReEvaluationJobRequest> _jobQueue;
private readonly ConcurrentDictionary<string, ReEvaluationJobRequest> _jobIndex;
private readonly ConcurrentQueue<PolicyEffectiveEvent> _eventStream;
private readonly List<Func<PolicyEffectiveEvent, Task>> _eventHandlers;
private readonly object _handlersLock = new();
private const int MaxQueueSize = 10000;
private const int MaxEventStreamSize = 50000;
public PolicyEventProcessor(
ILogger<PolicyEventProcessor> logger,
TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jobQueue = new ConcurrentQueue<ReEvaluationJobRequest>();
_jobIndex = new ConcurrentDictionary<string, ReEvaluationJobRequest>(StringComparer.OrdinalIgnoreCase);
_eventStream = new ConcurrentQueue<PolicyEffectiveEvent>();
_eventHandlers = new List<Func<PolicyEffectiveEvent, Task>>();
}
/// <summary>
/// Processes a policy change event and schedules re-evaluation if needed.
/// </summary>
public async Task<string?> ProcessChangeEventAsync(
PolicyChangeEvent changeEvent,
string packId,
int packVersion,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy_event.process", ActivityKind.Internal);
activity?.SetTag("event.id", changeEvent.EventId);
activity?.SetTag("event.type", changeEvent.ChangeType.ToString());
activity?.SetTag("tenant.id", changeEvent.TenantId);
_logger.LogDebug(
"Processing policy change event {EventId}: {ChangeType} for tenant {TenantId}",
changeEvent.EventId, changeEvent.ChangeType, changeEvent.TenantId);
// Skip if event targets no subjects
if (changeEvent.AffectedPurls.IsDefaultOrEmpty &&
changeEvent.AffectedSbomIds.IsDefaultOrEmpty &&
changeEvent.AffectedProductKeys.IsDefaultOrEmpty)
{
_logger.LogDebug("Skipping event {EventId}: no affected subjects", changeEvent.EventId);
return null;
}
// Create re-evaluation job request
var jobId = ReEvaluationJobRequest.CreateJobId(
changeEvent.TenantId,
packId,
packVersion,
changeEvent.ChangeType.ToString(),
_timeProvider.GetUtcNow());
var jobRequest = new ReEvaluationJobRequest(
JobId: jobId,
TenantId: changeEvent.TenantId,
PackId: packId,
PackVersion: packVersion,
TriggerType: changeEvent.ChangeType.ToString(),
CorrelationId: changeEvent.CorrelationId,
CreatedAt: _timeProvider.GetUtcNow(),
Priority: MapPriority(changeEvent.Priority),
AdvisoryIds: changeEvent.AdvisoryId is not null
? ImmutableArray.Create(changeEvent.AdvisoryId)
: ImmutableArray<string>.Empty,
SubjectPurls: changeEvent.AffectedPurls,
SbomIds: changeEvent.AffectedSbomIds,
Metadata: changeEvent.Metadata);
// Schedule the job
var scheduledId = await ScheduleAsync(jobRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("job.id", scheduledId);
PolicyEngineTelemetry.PolicyEventsProcessed.Add(1);
return scheduledId;
}
/// <summary>
/// Processes results from a re-evaluation and emits effective events.
/// </summary>
public async Task ProcessReEvaluationResultsAsync(
string jobId,
string tenantId,
string packId,
int packVersion,
string triggerType,
string? correlationId,
IReadOnlyList<PolicyDecisionChange> changes,
long durationMs,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy_event.emit_results", ActivityKind.Internal);
activity?.SetTag("job.id", jobId);
activity?.SetTag("changes.count", changes.Count);
var now = _timeProvider.GetUtcNow();
var changedCount = 0;
// Emit individual effective events for each changed decision
foreach (var change in changes)
{
if (!change.HasChanged)
{
continue;
}
changedCount++;
var diff = PolicyDecisionDiff.Create(
change.OldStatus, change.NewStatus,
change.OldSeverity, change.NewSeverity,
change.OldRule, change.NewRule,
change.OldPriority, change.NewPriority,
change.OldAnnotations, change.NewAnnotations);
var evt = new PolicyEffectiveUpdatedEvent(
EventId: GenerateEventId(),
TenantId: tenantId,
Timestamp: now,
CorrelationId: correlationId,
PackId: packId,
PackVersion: packVersion,
SubjectPurl: change.SubjectPurl,
AdvisoryId: change.AdvisoryId,
TriggerType: triggerType,
Diff: diff);
await PublishEffectiveUpdatedAsync(evt, cancellationToken).ConfigureAwait(false);
}
// Emit batch completed event
var summary = ComputeBatchSummary(changes);
var batchEvent = new PolicyBatchCompletedEvent(
EventId: GenerateEventId(),
TenantId: tenantId,
Timestamp: now,
CorrelationId: correlationId,
BatchId: jobId,
TriggerType: triggerType,
SubjectsEvaluated: changes.Count,
DecisionsChanged: changedCount,
DurationMs: durationMs,
Summary: summary);
await PublishBatchCompletedAsync(batchEvent, cancellationToken).ConfigureAwait(false);
activity?.SetTag("decisions.changed", changedCount);
_logger.LogInformation(
"Re-evaluation {JobId} completed: {Evaluated} subjects, {Changed} decisions changed in {Duration}ms",
jobId, changes.Count, changedCount, durationMs);
}
/// <inheritdoc/>
public async Task PublishEffectiveUpdatedAsync(
PolicyEffectiveUpdatedEvent evt,
CancellationToken cancellationToken = default)
{
await PublishEventAsync(evt).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task PublishBatchCompletedAsync(
PolicyBatchCompletedEvent evt,
CancellationToken cancellationToken = default)
{
await PublishEventAsync(evt).ConfigureAwait(false);
}
/// <inheritdoc/>
public void RegisterHandler(Func<PolicyEffectiveEvent, Task> handler)
{
ArgumentNullException.ThrowIfNull(handler);
lock (_handlersLock)
{
_eventHandlers.Add(handler);
}
}
/// <inheritdoc/>
public Task<string> ScheduleAsync(ReEvaluationJobRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Check for duplicate
if (_jobIndex.ContainsKey(request.JobId))
{
_logger.LogDebug("Duplicate job {JobId} ignored", request.JobId);
return Task.FromResult(request.JobId);
}
// Enforce queue limit
if (_jobQueue.Count >= MaxQueueSize)
{
_logger.LogWarning("Job queue full, rejecting job {JobId}", request.JobId);
throw new InvalidOperationException("Re-evaluation job queue is full");
}
_jobIndex[request.JobId] = request;
_jobQueue.Enqueue(request);
PolicyEngineTelemetry.ReEvaluationJobsScheduled.Add(1);
_logger.LogDebug(
"Scheduled re-evaluation job {JobId}: {TriggerType} for {TenantId}/{PackId}@{Version}",
request.JobId, request.TriggerType, request.TenantId, request.PackId, request.PackVersion);
return Task.FromResult(request.JobId);
}
/// <inheritdoc/>
public int GetPendingJobCount() => _jobQueue.Count;
/// <inheritdoc/>
public ReEvaluationJobRequest? GetJob(string jobId)
{
_jobIndex.TryGetValue(jobId, out var job);
return job;
}
/// <summary>
/// Dequeues the next job for processing.
/// </summary>
public ReEvaluationJobRequest? DequeueJob()
{
if (_jobQueue.TryDequeue(out var job))
{
_jobIndex.TryRemove(job.JobId, out _);
return job;
}
return null;
}
/// <summary>
/// Gets recent effective events.
/// </summary>
public IReadOnlyList<PolicyEffectiveEvent> GetRecentEvents(int limit = 100)
{
return _eventStream
.ToArray()
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList()
.AsReadOnly();
}
private async Task PublishEventAsync(PolicyEffectiveEvent evt)
{
// Add to stream
_eventStream.Enqueue(evt);
// Trim if too large
while (_eventStream.Count > MaxEventStreamSize)
{
_eventStream.TryDequeue(out _);
}
// Invoke handlers
List<Func<PolicyEffectiveEvent, Task>> handlers;
lock (_handlersLock)
{
handlers = _eventHandlers.ToList();
}
foreach (var handler in handlers)
{
try
{
await handler(evt).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking event handler for {EventType}", evt.EventType);
}
}
PolicyEngineTelemetry.PolicyEffectiveEventsPublished.Add(1);
}
private static PolicyBatchSummary ComputeBatchSummary(IReadOnlyList<PolicyDecisionChange> changes)
{
var statusUpgrades = 0;
var statusDowngrades = 0;
var newBlocks = 0;
var blocksRemoved = 0;
var advisories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var purls = new HashSet<string>(StringComparer.Ordinal);
foreach (var change in changes)
{
advisories.Add(change.AdvisoryId);
purls.Add(change.SubjectPurl);
if (!change.HasChanged)
{
continue;
}
var severityChange = CompareSeverity(change.OldStatus, change.NewStatus);
if (severityChange > 0)
{
statusUpgrades++;
}
else if (severityChange < 0)
{
statusDowngrades++;
}
if (IsBlockStatus(change.NewStatus) && !IsBlockStatus(change.OldStatus))
{
newBlocks++;
}
else if (IsBlockStatus(change.OldStatus) && !IsBlockStatus(change.NewStatus))
{
blocksRemoved++;
}
}
return new PolicyBatchSummary(
StatusUpgrades: statusUpgrades,
StatusDowngrades: statusDowngrades,
NewBlocks: newBlocks,
BlocksRemoved: blocksRemoved,
AffectedAdvisories: advisories.OrderBy(a => a).ToImmutableArray(),
AffectedPurls: purls.OrderBy(p => p).Take(100).ToImmutableArray());
}
private static int CompareSeverity(string? oldStatus, string? newStatus)
{
var oldSeverity = GetStatusSeverityLevel(oldStatus);
var newSeverity = GetStatusSeverityLevel(newStatus);
return newSeverity.CompareTo(oldSeverity);
}
private static int GetStatusSeverityLevel(string? status) => status?.ToLowerInvariant() switch
{
"blocked" => 4,
"deny" => 4,
"warn" => 3,
"affected" => 2,
"allow" => 1,
"ignored" => 0,
_ => 1
};
private static bool IsBlockStatus(string? status) =>
string.Equals(status, "blocked", StringComparison.OrdinalIgnoreCase) ||
string.Equals(status, "deny", StringComparison.OrdinalIgnoreCase);
private static Events.PolicyChangePriority MapPriority(IncrementalOrchestrator.PolicyChangePriority priority) =>
priority switch
{
IncrementalOrchestrator.PolicyChangePriority.Emergency => Events.PolicyChangePriority.Emergency,
IncrementalOrchestrator.PolicyChangePriority.High => Events.PolicyChangePriority.High,
_ => Events.PolicyChangePriority.Normal
};
private static string GenerateEventId()
{
var guid = Guid.NewGuid().ToByteArray();
return $"pee-{Convert.ToHexStringLower(guid)[..16]}";
}
}
/// <summary>
/// Represents a change in policy decision for a subject.
/// </summary>
public sealed record PolicyDecisionChange(
string SubjectPurl,
string AdvisoryId,
string? OldStatus,
string NewStatus,
string? OldSeverity,
string? NewSeverity,
string? OldRule,
string? NewRule,
int? OldPriority,
int? NewPriority,
ImmutableDictionary<string, string>? OldAnnotations,
ImmutableDictionary<string, string>? NewAnnotations)
{
/// <summary>
/// Whether the decision has changed.
/// </summary>
public bool HasChanged =>
!string.Equals(OldStatus, NewStatus, StringComparison.Ordinal) ||
!string.Equals(OldSeverity, NewSeverity, StringComparison.Ordinal) ||
!string.Equals(OldRule, NewRule, StringComparison.Ordinal);
}

View File

@@ -0,0 +1,225 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Cached exception entry for fast lookup during policy evaluation.
/// </summary>
public sealed record ExceptionCacheEntry
{
/// <summary>
/// Exception identifier.
/// </summary>
[JsonPropertyName("exception_id")]
public required string ExceptionId { get; init; }
/// <summary>
/// Asset identifier this exception applies to.
/// </summary>
[JsonPropertyName("asset_id")]
public required string AssetId { get; init; }
/// <summary>
/// Advisory ID covered (null if applies to all advisories for asset).
/// </summary>
[JsonPropertyName("advisory_id")]
public string? AdvisoryId { get; init; }
/// <summary>
/// CVE ID covered (null if applies to all CVEs for asset).
/// </summary>
[JsonPropertyName("cve_id")]
public string? CveId { get; init; }
/// <summary>
/// Decision override applied by this exception.
/// </summary>
[JsonPropertyName("decision_override")]
public required string DecisionOverride { get; init; }
/// <summary>
/// Exception type: waiver, override, temporary, permanent.
/// </summary>
[JsonPropertyName("exception_type")]
public required string ExceptionType { get; init; }
/// <summary>
/// Priority for conflict resolution (higher = more precedence).
/// </summary>
[JsonPropertyName("priority")]
public int Priority { get; init; }
/// <summary>
/// When the exception becomes effective.
/// </summary>
[JsonPropertyName("effective_from")]
public DateTimeOffset EffectiveFrom { get; init; }
/// <summary>
/// When the exception expires (null = no expiration).
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// When this cache entry was created.
/// </summary>
[JsonPropertyName("cached_at")]
public DateTimeOffset CachedAt { get; init; }
/// <summary>
/// Original exception name for display.
/// </summary>
[JsonPropertyName("exception_name")]
public string? ExceptionName { get; init; }
}
/// <summary>
/// Result of querying exceptions for an asset.
/// </summary>
public sealed record ExceptionCacheQueryResult
{
/// <summary>
/// Applicable exceptions for the asset, ordered by priority (highest first).
/// </summary>
public required ImmutableArray<ExceptionCacheEntry> Entries { get; init; }
/// <summary>
/// Whether the result came from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Cache version at time of query.
/// </summary>
public long CacheVersion { get; init; }
/// <summary>
/// Time taken to query in milliseconds.
/// </summary>
public long QueryDurationMs { get; init; }
}
/// <summary>
/// Summary of cached exceptions for a tenant.
/// </summary>
public sealed record ExceptionCacheSummary
{
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Total cached exception entries.
/// </summary>
public int TotalEntries { get; init; }
/// <summary>
/// Unique exceptions in cache.
/// </summary>
public int UniqueExceptions { get; init; }
/// <summary>
/// Unique assets with exceptions.
/// </summary>
public int UniqueAssets { get; init; }
/// <summary>
/// Counts by exception type.
/// </summary>
public required IReadOnlyDictionary<string, int> ByType { get; init; }
/// <summary>
/// Counts by decision override.
/// </summary>
public required IReadOnlyDictionary<string, int> ByDecision { get; init; }
/// <summary>
/// Entries expiring within the next hour.
/// </summary>
public int ExpiringWithinHour { get; init; }
/// <summary>
/// Cache version.
/// </summary>
public long CacheVersion { get; init; }
/// <summary>
/// When summary was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Options for exception cache operations.
/// </summary>
public sealed record ExceptionCacheOptions
{
/// <summary>
/// Default TTL for cache entries in minutes.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 60;
/// <summary>
/// Whether to enable automatic cache warming.
/// </summary>
public bool EnableAutoWarm { get; set; } = true;
/// <summary>
/// Warm interval in minutes.
/// </summary>
public int WarmIntervalMinutes { get; set; } = 15;
/// <summary>
/// Maximum entries per tenant.
/// </summary>
public int MaxEntriesPerTenant { get; set; } = 50000;
/// <summary>
/// Whether to invalidate cache on exception events.
/// </summary>
public bool InvalidateOnEvents { get; set; } = true;
}
/// <summary>
/// Statistics for the exception cache.
/// </summary>
public sealed record ExceptionCacheStats
{
/// <summary>
/// Total entries in cache.
/// </summary>
public int TotalEntries { get; init; }
/// <summary>
/// Total tenants with cached data.
/// </summary>
public int TotalTenants { get; init; }
/// <summary>
/// Memory used by cache in bytes (if available).
/// </summary>
public long? MemoryUsedBytes { get; init; }
/// <summary>
/// Cache hit count since last reset.
/// </summary>
public long HitCount { get; init; }
/// <summary>
/// Cache miss count since last reset.
/// </summary>
public long MissCount { get; init; }
/// <summary>
/// Last warm operation timestamp.
/// </summary>
public DateTimeOffset? LastWarmAt { get; init; }
/// <summary>
/// Last invalidation timestamp.
/// </summary>
public DateTimeOffset? LastInvalidationAt { get; init; }
}

View File

@@ -0,0 +1,156 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Interface for caching effective exception decisions per asset.
/// Supports warm/invalidation logic reacting to exception events.
/// </summary>
internal interface IExceptionEffectiveCache
{
/// <summary>
/// Gets applicable exceptions for an asset at a given time.
/// </summary>
Task<ExceptionCacheQueryResult> GetForAssetAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets applicable exceptions for multiple assets.
/// </summary>
Task<IReadOnlyDictionary<string, ExceptionCacheQueryResult>> GetBatchAsync(
string tenantId,
IReadOnlyList<string> assetIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets a cache entry.
/// </summary>
Task SetAsync(
string tenantId,
ExceptionCacheEntry entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets multiple cache entries in batch.
/// </summary>
Task SetBatchAsync(
string tenantId,
IEnumerable<ExceptionCacheEntry> entries,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cache entries for an exception.
/// Called when an exception is modified/revoked/expired.
/// </summary>
Task InvalidateExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cache entries for an asset.
/// Called when asset exceptions need re-evaluation.
/// </summary>
Task InvalidateAssetAsync(
string tenantId,
string assetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all cache entries for a tenant.
/// </summary>
Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Warms the cache for a tenant by loading active exceptions from the repository.
/// </summary>
Task WarmAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache summary for a tenant.
/// </summary>
Task<ExceptionCacheSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
Task<ExceptionCacheStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current cache version for a tenant.
/// </summary>
Task<long> GetVersionAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Processes an exception event and updates cache accordingly.
/// </summary>
Task HandleExceptionEventAsync(
ExceptionEvent exceptionEvent,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Event representing a change to an exception.
/// </summary>
public sealed record ExceptionEvent
{
/// <summary>
/// Event type: activated, expired, revoked, updated, created, deleted.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Exception identifier.
/// </summary>
public required string ExceptionId { get; init; }
/// <summary>
/// Exception name.
/// </summary>
public string? ExceptionName { get; init; }
/// <summary>
/// Exception type.
/// </summary>
public string? ExceptionType { get; init; }
/// <summary>
/// Affected asset IDs (if known).
/// </summary>
public ImmutableArray<string> AffectedAssetIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Affected advisory IDs (if known).
/// </summary>
public ImmutableArray<string> AffectedAdvisoryIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// When the event occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
}

View File

@@ -0,0 +1,725 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
using StellaOps.Policy.Engine.Telemetry;
using StackExchange.Redis;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Redis-backed exception effective cache with warm/invalidation support.
/// Key structure:
/// - Entry by asset: stellaops:exc:{tenant}:a:{asset}:{advisory|all} -> JSON array of entries
/// - Entry by exception: stellaops:exc:{tenant}:e:{exceptionId} -> JSON entry
/// - Index by exception: stellaops:exc:{tenant}:idx:e:{exceptionId} -> set of asset keys
/// - Version: stellaops:exc:{tenant}:v -> integer version
/// - Stats: stellaops:exc:{tenant}:stats -> JSON stats
/// </summary>
internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
{
private readonly IConnectionMultiplexer _redis;
private readonly IExceptionRepository _repository;
private readonly ILogger<RedisExceptionEffectiveCache> _logger;
private readonly ExceptionCacheOptions _options;
private readonly TimeProvider _timeProvider;
private const string KeyPrefix = "stellaops:exc";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public RedisExceptionEffectiveCache(
IConnectionMultiplexer redis,
IExceptionRepository repository,
ILogger<RedisExceptionEffectiveCache> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.ExceptionCache ?? new ExceptionCacheOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExceptionCacheQueryResult> GetForAssetAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var db = _redis.GetDatabase();
// Try specific advisory key first, then fall back to "all"
var entries = new List<ExceptionCacheEntry>();
var fromCache = false;
if (advisoryId is not null)
{
var specificKey = GetAssetKey(tenantId, assetId, advisoryId);
var specificJson = await db.StringGetAsync(specificKey).ConfigureAwait(false);
if (specificJson.HasValue)
{
var specificEntries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)specificJson!, JsonOptions);
if (specificEntries is not null)
{
entries.AddRange(specificEntries);
fromCache = true;
}
}
}
// Also get "all" entries (exceptions without specific advisory)
var allKey = GetAssetKey(tenantId, assetId, null);
var allJson = await db.StringGetAsync(allKey).ConfigureAwait(false);
if (allJson.HasValue)
{
var allEntries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)allJson!, JsonOptions);
if (allEntries is not null)
{
entries.AddRange(allEntries);
fromCache = true;
}
}
// Filter by time and sort by priority
var validEntries = entries
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
sw.Stop();
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, fromCache ? "hit" : "miss");
return new ExceptionCacheQueryResult
{
Entries = validEntries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = sw.ElapsedMilliseconds,
};
}
public async Task<IReadOnlyDictionary<string, ExceptionCacheQueryResult>> GetBatchAsync(
string tenantId,
IReadOnlyList<string> assetIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, ExceptionCacheQueryResult>(StringComparer.OrdinalIgnoreCase);
var db = _redis.GetDatabase();
// Get all "all" keys for assets
var keys = assetIds.Select(id => (RedisKey)GetAssetKey(tenantId, id, null)).ToArray();
var values = await db.StringGetAsync(keys).ConfigureAwait(false);
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
for (int i = 0; i < assetIds.Count; i++)
{
var entries = ImmutableArray<ExceptionCacheEntry>.Empty;
var fromCache = false;
if (values[i].HasValue)
{
var cachedEntries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)values[i]!, JsonOptions);
if (cachedEntries is not null)
{
entries = cachedEntries
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
fromCache = true;
}
}
results[assetIds[i]] = new ExceptionCacheQueryResult
{
Entries = entries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = 0,
};
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "batch_get");
return results;
}
public async Task SetAsync(
string tenantId,
ExceptionCacheEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var db = _redis.GetDatabase();
var assetKey = GetAssetKey(tenantId, entry.AssetId, entry.AdvisoryId);
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
// Get existing entries for this asset
var existingJson = await db.StringGetAsync(assetKey).ConfigureAwait(false);
var entries = existingJson.HasValue
? JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)existingJson!, JsonOptions) ?? new List<ExceptionCacheEntry>()
: new List<ExceptionCacheEntry>();
// Remove existing entry for same exception if any
entries.RemoveAll(e => e.ExceptionId == entry.ExceptionId);
// Add new entry
entries.Add(entry);
var ttl = ComputeTtl(entry);
var json = JsonSerializer.Serialize(entries, JsonOptions);
var tasks = new List<Task>
{
db.StringSetAsync(assetKey, json, ttl),
db.SetAddAsync(exceptionIndexKey, assetKey),
db.KeyExpireAsync(exceptionIndexKey, ttl + TimeSpan.FromMinutes(5)),
};
await Task.WhenAll(tasks).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set");
}
public async Task SetBatchAsync(
string tenantId,
IEnumerable<ExceptionCacheEntry> entries,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var batch = db.CreateBatch();
var count = 0;
// Group entries by asset+advisory
var groupedEntries = entries
.GroupBy(e => GetAssetKey(tenantId, e.AssetId, e.AdvisoryId))
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var (assetKey, assetEntries) in groupedEntries)
{
var ttl = assetEntries.Max(e => ComputeTtl(e));
var json = JsonSerializer.Serialize(assetEntries, JsonOptions);
_ = batch.StringSetAsync(assetKey, json, ttl);
// Update exception indexes
foreach (var entry in assetEntries)
{
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
_ = batch.SetAddAsync(exceptionIndexKey, assetKey);
_ = batch.KeyExpireAsync(exceptionIndexKey, ttl + TimeSpan.FromMinutes(5));
}
count += assetEntries.Count;
}
batch.Execute();
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set_batch");
_logger.LogDebug("Set {Count} exception cache entries for tenant {TenantId}", count, tenantId);
}
public async Task InvalidateExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var exceptionIndexKey = GetExceptionIndexKey(tenantId, exceptionId);
// Get all asset keys affected by this exception
var assetKeys = await db.SetMembersAsync(exceptionIndexKey).ConfigureAwait(false);
if (assetKeys.Length > 0)
{
// For each asset key, remove entries for this exception
foreach (var assetKey in assetKeys)
{
var json = await db.StringGetAsync((string)assetKey!).ConfigureAwait(false);
if (json.HasValue)
{
var entries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)json!, JsonOptions);
if (entries is not null)
{
entries.RemoveAll(e => e.ExceptionId == exceptionId);
if (entries.Count > 0)
{
await db.StringSetAsync((string)assetKey!, JsonSerializer.Serialize(entries, JsonOptions))
.ConfigureAwait(false);
}
else
{
await db.KeyDeleteAsync((string)assetKey!).ConfigureAwait(false);
}
}
}
}
}
// Delete the exception index
await db.KeyDeleteAsync(exceptionIndexKey).ConfigureAwait(false);
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_exception");
_logger.LogInformation(
"Invalidated exception {ExceptionId} affecting {Count} assets for tenant {TenantId}",
exceptionId, assetKeys.Length, tenantId);
}
public async Task InvalidateAssetAsync(
string tenantId,
string assetId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var server = _redis.GetServer(_redis.GetEndPoints().First());
// Find all keys for this asset (all advisory variants)
var pattern = $"{KeyPrefix}:{tenantId}:a:{assetId}:*";
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_asset");
_logger.LogDebug("Invalidated {Count} cache keys for asset {AssetId}", keys.Length, assetId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = $"{KeyPrefix}:{tenantId}:*";
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync(keys).ConfigureAwait(false);
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_tenant");
_logger.LogInformation("Invalidated {Count} cache keys for tenant {TenantId}", keys.Length, tenantId);
}
public async Task WarmAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.warm", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
var sw = Stopwatch.StartNew();
var now = _timeProvider.GetUtcNow();
_logger.LogInformation("Starting cache warm for tenant {TenantId}", tenantId);
try
{
// Get all active exceptions from repository
var exceptions = await _repository.ListExceptionsAsync(
tenantId,
new ExceptionQueryOptions
{
Statuses = ImmutableArray.Create("active"),
IncludeExpired = false,
Limit = _options.MaxEntriesPerTenant,
},
cancellationToken).ConfigureAwait(false);
if (exceptions.Length == 0)
{
_logger.LogDebug("No active exceptions to warm for tenant {TenantId}", tenantId);
return;
}
// Get bindings for all exceptions
var entries = new List<ExceptionCacheEntry>();
foreach (var exception in exceptions)
{
var bindings = await _repository.GetBindingsForExceptionAsync(
tenantId, exception.Id, cancellationToken).ConfigureAwait(false);
foreach (var binding in bindings.Where(b => b.Status == "active"))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = binding.AssetId,
AdvisoryId = binding.AdvisoryId,
CveId = binding.CveId,
DecisionOverride = binding.DecisionOverride,
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = binding.EffectiveFrom,
ExpiresAt = binding.ExpiresAt ?? exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
// Also add entries for scope-based exceptions without explicit bindings
if (exception.Scope.ApplyToAll || exception.Scope.AssetIds.Count > 0)
{
foreach (var assetId in exception.Scope.AssetIds)
{
foreach (var advisoryId in exception.Scope.AdvisoryIds.DefaultIfEmpty(null!))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = assetId,
AdvisoryId = advisoryId,
CveId = null,
DecisionOverride = "allow",
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = exception.EffectiveFrom ?? exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
}
}
}
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
sw.Stop();
// Update warm stats
await UpdateWarmStatsAsync(tenantId, now, entries.Count).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "warm");
_logger.LogInformation(
"Warmed cache with {Count} entries from {ExceptionCount} exceptions for tenant {TenantId} in {Duration}ms",
entries.Count, exceptions.Length, tenantId, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to warm cache for tenant {TenantId}", tenantId);
PolicyEngineTelemetry.RecordError("exception_cache_warm", tenantId);
throw;
}
}
public async Task<ExceptionCacheSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var db = _redis.GetDatabase();
var now = _timeProvider.GetUtcNow();
// Count asset keys
var assetPattern = $"{KeyPrefix}:{tenantId}:a:*";
var assetKeys = server.Keys(pattern: assetPattern).ToArray();
// Count exception index keys
var exceptionPattern = $"{KeyPrefix}:{tenantId}:idx:e:*";
var exceptionKeys = server.Keys(pattern: exceptionPattern).ToArray();
// Aggregate stats
var byType = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var byDecision = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var totalEntries = 0;
var expiringWithinHour = 0;
var uniqueAssets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in assetKeys.Take(1000)) // Limit scan for performance
{
var json = await db.StringGetAsync(key).ConfigureAwait(false);
if (!json.HasValue) continue;
var entries = JsonSerializer.Deserialize<List<ExceptionCacheEntry>>((string)json!, JsonOptions);
if (entries is null) continue;
foreach (var entry in entries)
{
totalEntries++;
uniqueAssets.Add(entry.AssetId);
byType.TryGetValue(entry.ExceptionType, out var typeCount);
byType[entry.ExceptionType] = typeCount + 1;
byDecision.TryGetValue(entry.DecisionOverride, out var decisionCount);
byDecision[entry.DecisionOverride] = decisionCount + 1;
if (entry.ExpiresAt.HasValue && entry.ExpiresAt.Value - now <= TimeSpan.FromHours(1))
{
expiringWithinHour++;
}
}
}
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ExceptionCacheSummary
{
TenantId = tenantId,
TotalEntries = totalEntries,
UniqueExceptions = exceptionKeys.Length,
UniqueAssets = uniqueAssets.Count,
ByType = byType,
ByDecision = byDecision,
ExpiringWithinHour = expiringWithinHour,
CacheVersion = version,
ComputedAt = now,
};
}
public async Task<ExceptionCacheStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var pattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:a:*"
: $"{KeyPrefix}:*:a:*";
var entryCount = server.Keys(pattern: pattern).Count();
var tenantPattern = tenantId != null
? $"{KeyPrefix}:{tenantId}:v"
: $"{KeyPrefix}:*:v";
var tenantCount = server.Keys(pattern: tenantPattern).Count();
long? memoryUsed = null;
try
{
var info = server.Info("memory");
var memorySection = info.FirstOrDefault(s => s.Key == "Memory");
if (memorySection is not null)
{
var usedMemory = memorySection.FirstOrDefault(p => p.Key == "used_memory");
if (usedMemory.Key is not null && long.TryParse(usedMemory.Value, out var bytes))
{
memoryUsed = bytes;
}
}
}
catch
{
// Ignore - memory info not available
}
return new ExceptionCacheStats
{
TotalEntries = entryCount,
TotalTenants = tenantCount,
MemoryUsedBytes = memoryUsed,
HitCount = 0, // Would need to track separately
MissCount = 0,
LastWarmAt = null,
LastInvalidationAt = null,
};
}
public async Task<long> GetVersionAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId);
var version = await db.StringGetAsync(versionKey).ConfigureAwait(false);
return version.HasValue ? (long)version : 0;
}
public async Task HandleExceptionEventAsync(
ExceptionEvent exceptionEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(exceptionEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.handle_event", ActivityKind.Internal);
activity?.SetTag("tenant_id", exceptionEvent.TenantId);
activity?.SetTag("event_type", exceptionEvent.EventType);
activity?.SetTag("exception_id", exceptionEvent.ExceptionId);
_logger.LogDebug(
"Handling exception event {EventType} for exception {ExceptionId} tenant {TenantId}",
exceptionEvent.EventType, exceptionEvent.ExceptionId, exceptionEvent.TenantId);
switch (exceptionEvent.EventType.ToLowerInvariant())
{
case "activated":
// Warm the cache with the new exception
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "expired":
case "revoked":
case "deleted":
// Invalidate cache entries for this exception
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "updated":
// Invalidate and re-warm
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "created":
// Only warm if already active
var exception = await _repository.GetExceptionAsync(
exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken).ConfigureAwait(false);
if (exception?.Status == "active")
{
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
}
break;
default:
_logger.LogWarning("Unknown exception event type: {EventType}", exceptionEvent.EventType);
break;
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(exceptionEvent.TenantId, $"event_{exceptionEvent.EventType}");
}
private async Task WarmExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
var exception = await _repository.GetExceptionAsync(tenantId, exceptionId, cancellationToken)
.ConfigureAwait(false);
if (exception is null || exception.Status != "active")
{
return;
}
var now = _timeProvider.GetUtcNow();
var entries = new List<ExceptionCacheEntry>();
var bindings = await _repository.GetBindingsForExceptionAsync(tenantId, exceptionId, cancellationToken)
.ConfigureAwait(false);
foreach (var binding in bindings.Where(b => b.Status == "active"))
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id,
AssetId = binding.AssetId,
AdvisoryId = binding.AdvisoryId,
CveId = binding.CveId,
DecisionOverride = binding.DecisionOverride,
ExceptionType = exception.ExceptionType,
Priority = exception.Priority,
EffectiveFrom = binding.EffectiveFrom,
ExpiresAt = binding.ExpiresAt ?? exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
_logger.LogDebug(
"Warmed cache with {Count} entries for exception {ExceptionId}",
entries.Count, exceptionId);
}
private async Task<long> IncrementVersionAsync(string tenantId, CancellationToken cancellationToken)
{
var db = _redis.GetDatabase();
var versionKey = GetVersionKey(tenantId);
var newVersion = await db.StringIncrementAsync(versionKey).ConfigureAwait(false);
// Set TTL on version key if not already set
await db.KeyExpireAsync(versionKey, TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10), ExpireWhen.HasNoExpiry)
.ConfigureAwait(false);
return newVersion;
}
private async Task UpdateWarmStatsAsync(string tenantId, DateTimeOffset warmAt, int count)
{
var db = _redis.GetDatabase();
var statsKey = GetStatsKey(tenantId);
var stats = new Dictionary<string, string>
{
["lastWarmAt"] = warmAt.ToString("O"),
["lastWarmCount"] = count.ToString(),
};
await db.HashSetAsync(statsKey, stats.Select(kv => new HashEntry(kv.Key, kv.Value)).ToArray())
.ConfigureAwait(false);
}
private TimeSpan ComputeTtl(ExceptionCacheEntry entry)
{
if (entry.ExpiresAt.HasValue)
{
var ttl = entry.ExpiresAt.Value - _timeProvider.GetUtcNow();
if (ttl > TimeSpan.Zero)
{
return ttl;
}
}
return TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
private static string GetAssetKey(string tenantId, string assetId, string? advisoryId) =>
$"{KeyPrefix}:{tenantId}:a:{assetId}:{advisoryId ?? "all"}";
private static string GetExceptionIndexKey(string tenantId, string exceptionId) =>
$"{KeyPrefix}:{tenantId}:idx:e:{exceptionId}";
private static string GetVersionKey(string tenantId) =>
$"{KeyPrefix}:{tenantId}:v";
private static string GetStatsKey(string tenantId) =>
$"{KeyPrefix}:{tenantId}:stats";
}

View File

@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Telemetry;
@@ -33,6 +35,10 @@ public sealed class PolicyEngineOptions
public PolicyEvaluationCacheOptions EvaluationCache { get; } = new();
public EffectiveDecisionMapOptions EffectiveDecisionMap { get; } = new();
public ExceptionCacheOptions ExceptionCache { get; } = new();
public void Validate()
{
Authority.Validate();

View File

@@ -79,6 +79,7 @@ internal sealed class PolicyBundleService
Size: payload.Length,
CreatedAt: createdAt,
Payload: payload.ToImmutableArray(),
CompiledDocument: compileResult.Document,
AocMetadata: aocMetadata);
await _repository.StoreBundleAsync(packId, version, record, cancellationToken).ConfigureAwait(false);

View File

@@ -1,9 +1,12 @@
using System;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.PolicyDsl;
using DslCompiler = StellaOps.PolicyDsl.PolicyCompiler;
using DslCompilationResult = StellaOps.PolicyDsl.PolicyCompilationResult;
@@ -27,19 +30,25 @@ internal sealed class PolicyCompilationService
{
private readonly DslCompiler compiler;
private readonly PolicyComplexityAnalyzer complexityAnalyzer;
private readonly PolicyMetadataExtractor metadataExtractor;
private readonly IOptionsMonitor<PolicyEngineOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<PolicyCompilationService> _logger;
public PolicyCompilationService(
DslCompiler compiler,
PolicyComplexityAnalyzer complexityAnalyzer,
PolicyMetadataExtractor metadataExtractor,
IOptionsMonitor<PolicyEngineOptions> optionsMonitor,
TimeProvider timeProvider)
TimeProvider timeProvider,
ILogger<PolicyCompilationService>? logger = null)
{
this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
this.complexityAnalyzer = complexityAnalyzer ?? throw new ArgumentNullException(nameof(complexityAnalyzer));
this.metadataExtractor = metadataExtractor ?? throw new ArgumentNullException(nameof(metadataExtractor));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PolicyCompilationService>.Instance;
}
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
@@ -56,6 +65,9 @@ internal sealed class PolicyCompilationService
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
{
PolicyEngineTelemetry.RecordCompilation("unsupported_syntax", 0);
PolicyEngineTelemetry.RecordError("compilation");
_logger.LogWarning("Compilation rejected: unsupported syntax {Syntax}", request.Dsl.Syntax ?? "null");
return PolicyCompilationResultDto.FromFailure(
ImmutableArray.Create(PolicyIssue.Error(
DiagnosticCodes.UnsupportedSyntaxVersion,
@@ -65,13 +77,23 @@ internal sealed class PolicyCompilationService
durationMilliseconds: 0);
}
using var activity = PolicyEngineTelemetry.StartCompileActivity(policyId: null, version: request.Dsl.Syntax);
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);
var durationSeconds = elapsed.TotalSeconds;
if (!result.Success || result.Document is null)
{
PolicyEngineTelemetry.RecordCompilation("failure", durationSeconds);
PolicyEngineTelemetry.RecordError("compilation");
activity?.SetStatus(ActivityStatusCode.Error, "Compilation failed");
_logger.LogWarning(
"Policy compilation failed in {DurationMs}ms with {DiagnosticCount} diagnostics",
durationMilliseconds,
result.Diagnostics.IsDefault ? 0 : result.Diagnostics.Length);
return PolicyCompilationResultDto.FromFailure(result.Diagnostics, null, durationMilliseconds);
}
@@ -79,6 +101,9 @@ internal sealed class PolicyCompilationService
var diagnostics = result.Diagnostics.IsDefault ? ImmutableArray<PolicyIssue>.Empty : result.Diagnostics;
var limits = optionsMonitor.CurrentValue?.Compilation ?? new PolicyEngineCompilationOptions();
activity?.SetTag("policy.rule_count", result.Document.Rules.Length);
activity?.SetTag("policy.complexity_score", complexity.Score);
if (limits.EnforceComplexity && complexity.Score > limits.MaxComplexityScore)
{
var diagnostic = PolicyIssue.Error(
@@ -86,6 +111,12 @@ internal sealed class PolicyCompilationService
$"Policy complexity score {complexity.Score:F2} exceeds configured maximum {limits.MaxComplexityScore:F2}. Reduce rule count or expression depth.",
"$.rules");
diagnostics = AppendDiagnostic(diagnostics, diagnostic);
PolicyEngineTelemetry.RecordCompilation("complexity_exceeded", durationSeconds);
PolicyEngineTelemetry.RecordError("compilation");
activity?.SetStatus(ActivityStatusCode.Error, "Complexity exceeded");
_logger.LogWarning(
"Policy compilation rejected: complexity {Score:F2} exceeds limit {MaxScore:F2}",
complexity.Score, limits.MaxComplexityScore);
return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds);
}
@@ -96,10 +127,27 @@ internal sealed class PolicyCompilationService
$"Policy compilation time {durationMilliseconds} ms exceeded limit {limits.MaxDurationMilliseconds} ms.",
"$.dsl");
diagnostics = AppendDiagnostic(diagnostics, diagnostic);
PolicyEngineTelemetry.RecordCompilation("duration_exceeded", durationSeconds);
PolicyEngineTelemetry.RecordError("compilation");
activity?.SetStatus(ActivityStatusCode.Error, "Duration exceeded");
_logger.LogWarning(
"Policy compilation rejected: duration {DurationMs}ms exceeds limit {MaxDurationMs}ms",
durationMilliseconds, limits.MaxDurationMilliseconds);
return PolicyCompilationResultDto.FromFailure(diagnostics, complexity, durationMilliseconds);
}
return PolicyCompilationResultDto.FromSuccess(result, complexity, durationMilliseconds);
// Extract extended metadata (symbol table, rule index, documentation, coverage, hashes)
var metadata = metadataExtractor.Extract(result.Document, result.CanonicalRepresentation);
PolicyEngineTelemetry.RecordCompilation("success", durationSeconds);
activity?.SetStatus(ActivityStatusCode.Ok);
activity?.SetTag("policy.symbol_count", metadata.SymbolTable.Symbols.Length);
activity?.SetTag("policy.coverage_paths", metadata.CoverageMetadata.CoveragePaths.Length);
_logger.LogDebug(
"Policy compiled successfully in {DurationMs}ms: {RuleCount} rules, complexity {Score:F2}, {SymbolCount} symbols",
durationMilliseconds, result.Document.Rules.Length, complexity.Score, metadata.SymbolTable.Symbols.Length);
return PolicyCompilationResultDto.FromSuccess(result, complexity, metadata, durationMilliseconds);
}
private static ImmutableArray<PolicyIssue> AppendDiagnostic(ImmutableArray<PolicyIssue> diagnostics, PolicyIssue diagnostic)
@@ -119,17 +167,20 @@ internal sealed record PolicyCompilationResultDto(
ImmutableArray<byte> CanonicalRepresentation,
ImmutableArray<PolicyIssue> Diagnostics,
PolicyComplexityReport? Complexity,
long DurationMilliseconds)
long DurationMilliseconds,
IrDocument? Document = null,
PolicyCompileMetadata? Metadata = null)
{
public static PolicyCompilationResultDto FromFailure(
ImmutableArray<PolicyIssue> diagnostics,
PolicyComplexityReport? complexity,
long durationMilliseconds) =>
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds);
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds, null, null);
public static PolicyCompilationResultDto FromSuccess(
DslCompilationResult compilationResult,
PolicyComplexityReport complexity,
PolicyCompileMetadata metadata,
long durationMilliseconds)
{
if (compilationResult.Document is null)
@@ -145,7 +196,9 @@ internal sealed record PolicyCompilationResultDto(
compilationResult.CanonicalRepresentation,
compilationResult.Diagnostics,
complexity,
durationMilliseconds);
durationMilliseconds,
compilationResult.Document,
metadata);
}
}

View File

@@ -0,0 +1,497 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Query options for retrieving explain traces.
/// </summary>
public sealed record ExplainQueryOptions
{
/// <summary>
/// Filter by policy ID.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// Filter by policy version.
/// </summary>
public int? PolicyVersion { get; init; }
/// <summary>
/// Filter by run ID.
/// </summary>
public string? RunId { get; init; }
/// <summary>
/// Filter by component PURL.
/// </summary>
public string? ComponentPurl { get; init; }
/// <summary>
/// Filter by vulnerability ID.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Filter by final outcome.
/// </summary>
public string? FinalOutcome { get; init; }
/// <summary>
/// Filter by evaluation time range start.
/// </summary>
public DateTimeOffset? FromTime { get; init; }
/// <summary>
/// Filter by evaluation time range end.
/// </summary>
public DateTimeOffset? ToTime { get; init; }
/// <summary>
/// Maximum number of results to return.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Number of results to skip for pagination.
/// </summary>
public int Skip { get; init; } = 0;
/// <summary>
/// Include rule steps in results (can be large).
/// </summary>
public bool IncludeRuleSteps { get; init; } = true;
/// <summary>
/// Include VEX evidence in results.
/// </summary>
public bool IncludeVexEvidence { get; init; } = true;
}
/// <summary>
/// Stored explain trace with AOC chain reference.
/// </summary>
public sealed record StoredExplainTrace
{
/// <summary>
/// Unique identifier.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// The explain trace data.
/// </summary>
public required ExplainTrace Trace { get; init; }
/// <summary>
/// Reference to the AOC chain for this decision.
/// </summary>
public ExplainAocChain? AocChain { get; init; }
/// <summary>
/// When this trace was stored.
/// </summary>
public required DateTimeOffset StoredAt { get; init; }
}
/// <summary>
/// AOC chain linking a decision to its attestation chain.
/// </summary>
public sealed record ExplainAocChain
{
/// <summary>
/// Compilation ID that produced the policy bundle.
/// </summary>
public required string CompilationId { get; init; }
/// <summary>
/// Compiler version used.
/// </summary>
public required string CompilerVersion { get; init; }
/// <summary>
/// Source digest of the policy document.
/// </summary>
public required string SourceDigest { get; init; }
/// <summary>
/// Artifact digest of the compiled bundle.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Reference to the signed attestation.
/// </summary>
public ExplainAttestationRef? AttestationRef { get; init; }
/// <summary>
/// Provenance information.
/// </summary>
public ExplainProvenance? Provenance { get; init; }
}
/// <summary>
/// Attestation reference for AOC chain.
/// </summary>
public sealed record ExplainAttestationRef(
string AttestationId,
string EnvelopeDigest,
string? Uri,
string? SigningKeyId);
/// <summary>
/// Provenance for AOC chain.
/// </summary>
public sealed record ExplainProvenance(
string SourceType,
string? SourceUrl,
string? Submitter,
string? CommitSha,
string? Branch);
/// <summary>
/// Repository interface for explain trace persistence.
/// </summary>
public interface IExplainTraceRepository
{
/// <summary>
/// Stores an explain trace.
/// </summary>
Task<StoredExplainTrace> StoreAsync(
string tenantId,
ExplainTrace trace,
ExplainAocChain? aocChain,
TimeSpan? retention,
CancellationToken cancellationToken);
/// <summary>
/// Retrieves an explain trace by ID.
/// </summary>
Task<StoredExplainTrace?> GetByIdAsync(
string tenantId,
string id,
CancellationToken cancellationToken);
/// <summary>
/// Retrieves an explain trace by run ID and subject hash.
/// </summary>
Task<StoredExplainTrace?> GetByRunAndSubjectAsync(
string tenantId,
string runId,
string subjectHash,
CancellationToken cancellationToken);
/// <summary>
/// Queries explain traces with filtering and pagination.
/// </summary>
Task<IReadOnlyList<StoredExplainTrace>> QueryAsync(
string tenantId,
ExplainQueryOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Gets all explain traces for a policy run.
/// </summary>
Task<IReadOnlyList<StoredExplainTrace>> GetByRunIdAsync(
string tenantId,
string runId,
CancellationToken cancellationToken);
/// <summary>
/// Deletes explain traces older than the specified retention period.
/// </summary>
Task<int> PruneExpiredAsync(
string tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Service for persisting and retrieving policy explain traces with AOC chain linkage.
/// </summary>
internal sealed class PolicyExplainerService
{
private readonly IExplainTraceRepository _repository;
private readonly IPolicyPackRepository _policyRepository;
private readonly ILogger<PolicyExplainerService> _logger;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _defaultRetention;
public PolicyExplainerService(
IExplainTraceRepository repository,
IPolicyPackRepository policyRepository,
ILogger<PolicyExplainerService> logger,
TimeProvider timeProvider,
TimeSpan? defaultRetention = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_defaultRetention = defaultRetention ?? TimeSpan.FromDays(30);
}
/// <summary>
/// Stores an explain trace and links it to the AOC chain from the policy bundle.
/// </summary>
public async Task<StoredExplainTrace> StoreExplainTraceAsync(
string tenantId,
ExplainTrace trace,
TimeSpan? retention = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(trace);
_logger.LogDebug(
"Storing explain trace for run {RunId}, policy {PolicyId}:{Version}, tenant {TenantId}",
trace.RunId, trace.PolicyId, trace.PolicyVersion, tenantId);
// Try to get AOC chain from the policy bundle
ExplainAocChain? aocChain = null;
if (trace.PolicyVersion.HasValue)
{
var revision = await _policyRepository.GetRevisionAsync(
trace.PolicyId,
trace.PolicyVersion.Value,
cancellationToken).ConfigureAwait(false);
if (revision?.Bundle?.AocMetadata is not null)
{
var aoc = revision.Bundle.AocMetadata;
aocChain = new ExplainAocChain
{
CompilationId = aoc.CompilationId,
CompilerVersion = aoc.CompilerVersion,
SourceDigest = aoc.SourceDigest,
ArtifactDigest = aoc.ArtifactDigest,
AttestationRef = aoc.AttestationRef is not null
? new ExplainAttestationRef(
aoc.AttestationRef.AttestationId,
aoc.AttestationRef.EnvelopeDigest,
aoc.AttestationRef.Uri,
aoc.AttestationRef.SigningKeyId)
: null,
Provenance = aoc.Provenance is not null
? new ExplainProvenance(
aoc.Provenance.SourceType,
aoc.Provenance.SourceUrl,
aoc.Provenance.Submitter,
aoc.Provenance.CommitSha,
aoc.Provenance.Branch)
: null
};
_logger.LogDebug(
"Linked explain trace to AOC chain: compilation {CompilationId}, attestation {AttestationId}",
aocChain.CompilationId,
aocChain.AttestationRef?.AttestationId ?? "(none)");
}
}
var stored = await _repository.StoreAsync(
tenantId,
trace,
aocChain,
retention ?? _defaultRetention,
cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.ExplainTracesStored.Add(1,
new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("policy_id", trace.PolicyId));
return stored;
}
/// <summary>
/// Retrieves an explain trace by its ID.
/// </summary>
public Task<StoredExplainTrace?> GetExplainTraceAsync(
string tenantId,
string traceId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(traceId);
return _repository.GetByIdAsync(tenantId, traceId, cancellationToken);
}
/// <summary>
/// Retrieves an explain trace for a specific decision.
/// </summary>
public Task<StoredExplainTrace?> GetExplainTraceForDecisionAsync(
string tenantId,
string runId,
string subjectHash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(runId);
ArgumentNullException.ThrowIfNull(subjectHash);
return _repository.GetByRunAndSubjectAsync(tenantId, runId, subjectHash, cancellationToken);
}
/// <summary>
/// Gets all explain traces for a policy run.
/// </summary>
public Task<IReadOnlyList<StoredExplainTrace>> GetExplainTracesForRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(runId);
return _repository.GetByRunIdAsync(tenantId, runId, cancellationToken);
}
/// <summary>
/// Queries explain traces with filtering and pagination.
/// </summary>
public Task<IReadOnlyList<StoredExplainTrace>> QueryExplainTracesAsync(
string tenantId,
ExplainQueryOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
options ??= new ExplainQueryOptions();
return _repository.QueryAsync(tenantId, options, cancellationToken);
}
/// <summary>
/// Gets the AOC chain for a stored explain trace.
/// </summary>
public async Task<ExplainAocChain?> GetAocChainForTraceAsync(
string tenantId,
string traceId,
CancellationToken cancellationToken = default)
{
var trace = await GetExplainTraceAsync(tenantId, traceId, cancellationToken).ConfigureAwait(false);
return trace?.AocChain;
}
/// <summary>
/// Validates that an explain trace's AOC chain is intact.
/// </summary>
public async Task<AocChainValidationResult> ValidateAocChainAsync(
string tenantId,
string traceId,
CancellationToken cancellationToken = default)
{
var trace = await GetExplainTraceAsync(tenantId, traceId, cancellationToken).ConfigureAwait(false);
if (trace is null)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "Explain trace not found",
PolicyFound: false,
BundleIntact: false,
AttestationAvailable: false);
}
if (trace.AocChain is null)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "No AOC chain linked to this trace",
PolicyFound: true,
BundleIntact: false,
AttestationAvailable: false);
}
// Verify the policy revision still exists
if (!trace.Trace.PolicyVersion.HasValue)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "Trace has no policy version",
PolicyFound: false,
BundleIntact: false,
AttestationAvailable: false);
}
var revision = await _policyRepository.GetRevisionAsync(
trace.Trace.PolicyId,
trace.Trace.PolicyVersion.Value,
cancellationToken).ConfigureAwait(false);
if (revision is null)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: $"Policy revision {trace.Trace.PolicyId}:{trace.Trace.PolicyVersion} no longer exists",
PolicyFound: false,
BundleIntact: false,
AttestationAvailable: false);
}
// Verify bundle digest matches
var bundleIntact = revision.Bundle?.Digest == trace.AocChain.ArtifactDigest;
if (!bundleIntact)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "Bundle digest mismatch - policy bundle has been modified",
PolicyFound: true,
BundleIntact: false,
AttestationAvailable: trace.AocChain.AttestationRef is not null);
}
// Verify AOC metadata matches
var aocMatches = revision.Bundle?.AocMetadata?.CompilationId == trace.AocChain.CompilationId &&
revision.Bundle?.AocMetadata?.SourceDigest == trace.AocChain.SourceDigest;
if (!aocMatches)
{
return new AocChainValidationResult(
IsValid: false,
ValidationMessage: "AOC metadata mismatch - compilation chain has been modified",
PolicyFound: true,
BundleIntact: true,
AttestationAvailable: trace.AocChain.AttestationRef is not null);
}
return new AocChainValidationResult(
IsValid: true,
ValidationMessage: "AOC chain is intact and verifiable",
PolicyFound: true,
BundleIntact: true,
AttestationAvailable: trace.AocChain.AttestationRef is not null);
}
/// <summary>
/// Prunes expired explain traces for a tenant.
/// </summary>
public async Task<int> PruneExpiredTracesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenantId);
var pruned = await _repository.PruneExpiredAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (pruned > 0)
{
_logger.LogInformation(
"Pruned {Count} expired explain traces for tenant {TenantId}",
pruned, tenantId);
}
return pruned;
}
}
/// <summary>
/// Result of AOC chain validation.
/// </summary>
public sealed record AocChainValidationResult(
bool IsValid,
string ValidationMessage,
bool PolicyFound,
bool BundleIntact,
bool AttestationAvailable);

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -6,6 +7,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Services;
@@ -88,6 +90,12 @@ internal sealed class PolicyRuntimeEvaluationService
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.StartEvaluateActivity(
request.TenantId, request.PackId, runId: null);
activity?.SetTag("policy.version", request.Version);
activity?.SetTag("subject.purl", request.SubjectPurl);
activity?.SetTag("advisory.id", request.AdvisoryId);
var startTimestamp = _timeProvider.GetTimestamp();
var evaluationTimestamp = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
@@ -97,6 +105,9 @@ internal sealed class PolicyRuntimeEvaluationService
if (bundle is null)
{
PolicyEngineTelemetry.RecordError("evaluation", request.TenantId);
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, request.PackId, "bundle_not_found");
activity?.SetStatus(ActivityStatusCode.Error, "Bundle not found");
throw new InvalidOperationException(
$"Policy bundle not found for pack '{request.PackId}' version {request.Version}.");
}
@@ -113,6 +124,12 @@ internal sealed class PolicyRuntimeEvaluationService
if (cacheResult.CacheHit && cacheResult.Entry is not null)
{
var duration = GetElapsedMilliseconds(startTimestamp);
var durationSeconds = duration / 1000.0;
PolicyEngineTelemetry.RecordEvaluationLatency(durationSeconds, request.TenantId, request.PackId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, request.PackId, "cached");
activity?.SetTag("cache.hit", true);
activity?.SetTag("cache.source", cacheResult.Source.ToString());
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Cache hit for evaluation {PackId}@{Version} subject {Subject} from {Source}",
request.PackId, request.Version, request.SubjectPurl, cacheResult.Source);
@@ -122,12 +139,17 @@ internal sealed class PolicyRuntimeEvaluationService
}
}
activity?.SetTag("cache.hit", false);
// Cache miss - perform evaluation
var document = DeserializeCompiledPolicy(bundle.Payload);
var document = bundle.CompiledDocument;
if (document is null)
{
PolicyEngineTelemetry.RecordError("evaluation", request.TenantId);
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, request.PackId, "document_not_found");
activity?.SetStatus(ActivityStatusCode.Error, "Document not found");
throw new InvalidOperationException(
$"Failed to deserialize compiled policy for pack '{request.PackId}' version {request.Version}.");
$"Compiled policy document not found for pack '{request.PackId}' version {request.Version}.");
}
var context = new PolicyEvaluationContext(
@@ -162,6 +184,21 @@ internal sealed class PolicyRuntimeEvaluationService
await _cache.SetAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false);
var evalDuration = GetElapsedMilliseconds(startTimestamp);
var evalDurationSeconds = evalDuration / 1000.0;
// Record metrics
PolicyEngineTelemetry.RecordEvaluationLatency(evalDurationSeconds, request.TenantId, request.PackId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, request.PackId, "full");
if (!string.IsNullOrEmpty(result.RuleName))
{
PolicyEngineTelemetry.RecordRuleFired(request.PackId, result.RuleName);
}
activity?.SetTag("evaluation.status", result.Status);
activity?.SetTag("evaluation.rule", result.RuleName ?? "none");
activity?.SetTag("evaluation.duration_ms", evalDuration);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Evaluated {PackId}@{Version} subject {Subject} in {Duration}ms - {Status}",
request.PackId, request.Version, request.SubjectPurl, evalDuration, result.Status);
@@ -195,7 +232,13 @@ internal sealed class PolicyRuntimeEvaluationService
return Array.Empty<RuntimeEvaluationResponse>();
}
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy.evaluate_batch", ActivityKind.Internal);
activity?.SetTag("batch.size", requests.Count);
var batchStartTimestamp = _timeProvider.GetTimestamp();
var results = new List<RuntimeEvaluationResponse>(requests.Count);
var cacheHits = 0;
var cacheMisses = 0;
// Group by pack/version for bundle loading efficiency
var groups = requests.GroupBy(r => (r.PackId, r.Version));
@@ -210,6 +253,7 @@ internal sealed class PolicyRuntimeEvaluationService
{
foreach (var request in group)
{
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, packId, "bundle_not_found");
_logger.LogWarning(
"Policy bundle not found for pack '{PackId}' version {Version}, skipping evaluation",
packId, version);
@@ -217,11 +261,12 @@ internal sealed class PolicyRuntimeEvaluationService
continue;
}
var document = DeserializeCompiledPolicy(bundle.Payload);
var document = bundle.CompiledDocument;
if (document is null)
{
PolicyEngineTelemetry.RecordEvaluationFailure("default", packId, "document_not_found");
_logger.LogWarning(
"Failed to deserialize policy bundle for pack '{PackId}' version {Version}",
"Compiled policy document not found for pack '{PackId}' version {Version}",
packId, version);
continue;
}
@@ -249,6 +294,8 @@ internal sealed class PolicyRuntimeEvaluationService
{
var response = CreateResponseFromCache(request, bundle.Digest, entry, CacheSource.InMemory, 0);
results.Add(response);
cacheHits++;
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, packId, "cached");
}
else
{
@@ -294,6 +341,15 @@ internal sealed class PolicyRuntimeEvaluationService
expiresAt);
entriesToCache[key] = cacheEntry;
cacheMisses++;
// Record metrics for each evaluation
PolicyEngineTelemetry.RecordEvaluationLatency(duration / 1000.0, request.TenantId, packId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, packId, "full");
if (!string.IsNullOrEmpty(result.RuleName))
{
PolicyEngineTelemetry.RecordRuleFired(packId, result.RuleName);
}
results.Add(new RuntimeEvaluationResponse(
request.PackId,
@@ -319,6 +375,17 @@ internal sealed class PolicyRuntimeEvaluationService
}
}
// Record batch-level metrics
var batchDuration = GetElapsedMilliseconds(batchStartTimestamp);
activity?.SetTag("batch.cache_hits", cacheHits);
activity?.SetTag("batch.cache_misses", cacheMisses);
activity?.SetTag("batch.duration_ms", batchDuration);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Batch evaluation completed: {Total} subjects, {CacheHits} cache hits, {CacheMisses} evaluated in {Duration}ms",
requests.Count, cacheHits, cacheMisses, batchDuration);
return results;
}
@@ -398,24 +465,6 @@ internal sealed class PolicyRuntimeEvaluationService
return Convert.ToHexString(hash);
}
private static PolicyIrDocument? DeserializeCompiledPolicy(ImmutableArray<byte> payload)
{
if (payload.IsDefaultOrEmpty)
{
return null;
}
try
{
var json = Encoding.UTF8.GetString(payload.AsSpan());
return JsonSerializer.Deserialize<PolicyIrDocument>(json);
}
catch
{
return null;
}
}
private long GetElapsedMilliseconds(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);

View File

@@ -61,7 +61,8 @@ public sealed record RiskSimulationResult(
[property: JsonPropertyName("distribution")] RiskDistribution? Distribution,
[property: JsonPropertyName("top_movers")] IReadOnlyList<TopMover>? TopMovers,
[property: JsonPropertyName("aggregate_metrics")] AggregateRiskMetrics AggregateMetrics,
[property: JsonPropertyName("execution_time_ms")] double ExecutionTimeMs);
[property: JsonPropertyName("execution_time_ms")] double ExecutionTimeMs,
[property: JsonPropertyName("analytics")] SimulationAnalytics? Analytics = null);
/// <summary>
/// Computed risk score for a finding.

View File

@@ -0,0 +1,236 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Extended simulation analytics including rule firing counts, heatmaps, traces, and delta summaries.
/// </summary>
public sealed record SimulationAnalytics(
[property: JsonPropertyName("rule_firing_counts")] RuleFiringCounts RuleFiringCounts,
[property: JsonPropertyName("heatmap")] SimulationHeatmap Heatmap,
[property: JsonPropertyName("sampled_traces")] SampledExplainTraces SampledTraces,
[property: JsonPropertyName("delta_summary")] SimulationDeltaSummary? DeltaSummary);
/// <summary>
/// Rule firing counts aggregated across simulation runs.
/// </summary>
public sealed record RuleFiringCounts(
[property: JsonPropertyName("total_evaluations")] int TotalEvaluations,
[property: JsonPropertyName("total_rules_fired")] int TotalRulesFired,
[property: JsonPropertyName("rules_by_name")] ImmutableDictionary<string, RuleFireCount> RulesByName,
[property: JsonPropertyName("rules_by_priority")] ImmutableDictionary<int, int> RulesByPriority,
[property: JsonPropertyName("rules_by_outcome")] ImmutableDictionary<string, int> RulesByOutcome,
[property: JsonPropertyName("rules_by_category")] ImmutableDictionary<string, int> RulesByCategory,
[property: JsonPropertyName("top_rules")] ImmutableArray<RuleFireCount> TopRules,
[property: JsonPropertyName("vex_override_counts")] VexOverrideCounts VexOverrides);
/// <summary>
/// Fire count for a single rule.
/// </summary>
public sealed record RuleFireCount(
[property: JsonPropertyName("rule_name")] string RuleName,
[property: JsonPropertyName("priority")] int Priority,
[property: JsonPropertyName("category")] string? Category,
[property: JsonPropertyName("fire_count")] int FireCount,
[property: JsonPropertyName("fire_percentage")] double FirePercentage,
[property: JsonPropertyName("outcomes")] ImmutableDictionary<string, int> OutcomeBreakdown,
[property: JsonPropertyName("avg_evaluation_us")] double AverageEvaluationMicroseconds);
/// <summary>
/// VEX override aggregation.
/// </summary>
public sealed record VexOverrideCounts(
[property: JsonPropertyName("total_overrides")] int TotalOverrides,
[property: JsonPropertyName("by_vendor")] ImmutableDictionary<string, int> ByVendor,
[property: JsonPropertyName("by_status")] ImmutableDictionary<string, int> ByStatus,
[property: JsonPropertyName("by_justification")] ImmutableDictionary<string, int> ByJustification);
/// <summary>
/// Heatmap aggregates for visualization.
/// </summary>
public sealed record SimulationHeatmap(
[property: JsonPropertyName("rule_severity_matrix")] ImmutableArray<HeatmapCell> RuleSeverityMatrix,
[property: JsonPropertyName("rule_outcome_matrix")] ImmutableArray<HeatmapCell> RuleOutcomeMatrix,
[property: JsonPropertyName("finding_rule_coverage")] FindingRuleCoverage FindingRuleCoverage,
[property: JsonPropertyName("temporal_distribution")] ImmutableArray<TemporalBucket> TemporalDistribution);
/// <summary>
/// A cell in the heatmap matrix.
/// </summary>
public sealed record HeatmapCell(
[property: JsonPropertyName("x")] string X,
[property: JsonPropertyName("y")] string Y,
[property: JsonPropertyName("value")] int Value,
[property: JsonPropertyName("normalized")] double Normalized);
/// <summary>
/// Coverage of findings by rules.
/// </summary>
public sealed record FindingRuleCoverage(
[property: JsonPropertyName("total_findings")] int TotalFindings,
[property: JsonPropertyName("findings_matched")] int FindingsMatched,
[property: JsonPropertyName("findings_unmatched")] int FindingsUnmatched,
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
[property: JsonPropertyName("rules_never_fired")] ImmutableArray<string> RulesNeverFired,
[property: JsonPropertyName("findings_by_match_count")] ImmutableDictionary<int, int> FindingsByMatchCount);
/// <summary>
/// Temporal distribution bucket.
/// </summary>
public sealed record TemporalBucket(
[property: JsonPropertyName("bucket_start_ms")] long BucketStartMs,
[property: JsonPropertyName("bucket_end_ms")] long BucketEndMs,
[property: JsonPropertyName("evaluation_count")] int EvaluationCount,
[property: JsonPropertyName("rules_fired")] int RulesFired);
/// <summary>
/// Sampled explain traces with deterministic ordering.
/// </summary>
public sealed record SampledExplainTraces(
[property: JsonPropertyName("sample_rate")] double SampleRate,
[property: JsonPropertyName("total_traces")] int TotalTraces,
[property: JsonPropertyName("sampled_count")] int SampledCount,
[property: JsonPropertyName("ordering")] TraceOrdering Ordering,
[property: JsonPropertyName("traces")] ImmutableArray<SampledTrace> Traces,
[property: JsonPropertyName("determinism_hash")] string DeterminismHash);
/// <summary>
/// Deterministic ordering specification.
/// </summary>
public sealed record TraceOrdering(
[property: JsonPropertyName("primary_key")] string PrimaryKey,
[property: JsonPropertyName("secondary_key")] string? SecondaryKey,
[property: JsonPropertyName("direction")] string Direction);
/// <summary>
/// A sampled trace with key metadata.
/// </summary>
public sealed record SampledTrace(
[property: JsonPropertyName("trace_id")] string TraceId,
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string? ComponentPurl,
[property: JsonPropertyName("advisory_id")] string? AdvisoryId,
[property: JsonPropertyName("final_outcome")] string FinalOutcome,
[property: JsonPropertyName("assigned_severity")] string? AssignedSeverity,
[property: JsonPropertyName("rules_evaluated")] int RulesEvaluated,
[property: JsonPropertyName("rules_fired")] int RulesFired,
[property: JsonPropertyName("vex_applied")] bool VexApplied,
[property: JsonPropertyName("evaluation_ms")] double EvaluationMs,
[property: JsonPropertyName("rule_sequence")] ImmutableArray<string> RuleSequence,
[property: JsonPropertyName("sample_reason")] string SampleReason);
/// <summary>
/// Delta summary comparing simulation results.
/// </summary>
public sealed record SimulationDeltaSummary(
[property: JsonPropertyName("comparison_type")] SimulationComparisonType ComparisonType,
[property: JsonPropertyName("base_policy_ref")] string BasePolicyRef,
[property: JsonPropertyName("candidate_policy_ref")] string? CandidatePolicyRef,
[property: JsonPropertyName("total_findings")] int TotalFindings,
[property: JsonPropertyName("outcome_changes")] OutcomeChangeSummary OutcomeChanges,
[property: JsonPropertyName("severity_changes")] SeverityChangeSummary SeverityChanges,
[property: JsonPropertyName("rule_changes")] RuleChangeSummary RuleChanges,
[property: JsonPropertyName("high_impact_findings")] ImmutableArray<HighImpactFinding> HighImpactFindings,
[property: JsonPropertyName("determinism_hash")] string DeterminismHash);
/// <summary>
/// Type of simulation comparison.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SimulationComparisonType>))]
public enum SimulationComparisonType
{
/// <summary>Single policy snapshot.</summary>
[JsonPropertyName("snapshot")]
Snapshot,
/// <summary>Comparing two policy versions.</summary>
[JsonPropertyName("version_compare")]
VersionCompare,
/// <summary>What-if analysis with hypothetical changes.</summary>
[JsonPropertyName("whatif")]
WhatIf,
/// <summary>Batch comparison across multiple inputs.</summary>
[JsonPropertyName("batch")]
Batch
}
/// <summary>
/// Summary of outcome changes.
/// </summary>
public sealed record OutcomeChangeSummary(
[property: JsonPropertyName("unchanged")] int Unchanged,
[property: JsonPropertyName("improved")] int Improved,
[property: JsonPropertyName("regressed")] int Regressed,
[property: JsonPropertyName("transitions")] ImmutableArray<OutcomeTransition> Transitions);
/// <summary>
/// A specific outcome transition.
/// </summary>
public sealed record OutcomeTransition(
[property: JsonPropertyName("from_outcome")] string FromOutcome,
[property: JsonPropertyName("to_outcome")] string ToOutcome,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage,
[property: JsonPropertyName("is_improvement")] bool IsImprovement);
/// <summary>
/// Summary of severity changes.
/// </summary>
public sealed record SeverityChangeSummary(
[property: JsonPropertyName("unchanged")] int Unchanged,
[property: JsonPropertyName("escalated")] int Escalated,
[property: JsonPropertyName("deescalated")] int Deescalated,
[property: JsonPropertyName("transitions")] ImmutableArray<SeverityTransition> Transitions);
/// <summary>
/// A specific severity transition.
/// </summary>
public sealed record SeverityTransition(
[property: JsonPropertyName("from_severity")] string FromSeverity,
[property: JsonPropertyName("to_severity")] string ToSeverity,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage);
/// <summary>
/// Summary of rule behavior changes.
/// </summary>
public sealed record RuleChangeSummary(
[property: JsonPropertyName("rules_added")] ImmutableArray<string> RulesAdded,
[property: JsonPropertyName("rules_removed")] ImmutableArray<string> RulesRemoved,
[property: JsonPropertyName("rules_modified")] ImmutableArray<RuleModification> RulesModified,
[property: JsonPropertyName("fire_rate_changes")] ImmutableArray<RuleFireRateChange> FireRateChanges);
/// <summary>
/// A rule modification between versions.
/// </summary>
public sealed record RuleModification(
[property: JsonPropertyName("rule_name")] string RuleName,
[property: JsonPropertyName("modification_type")] string ModificationType,
[property: JsonPropertyName("description")] string Description);
/// <summary>
/// Change in rule fire rate.
/// </summary>
public sealed record RuleFireRateChange(
[property: JsonPropertyName("rule_name")] string RuleName,
[property: JsonPropertyName("base_fire_rate")] double BaseFireRate,
[property: JsonPropertyName("candidate_fire_rate")] double CandidateFireRate,
[property: JsonPropertyName("change_percentage")] double ChangePercentage,
[property: JsonPropertyName("is_significant")] bool IsSignificant);
/// <summary>
/// A finding with high impact from policy changes.
/// </summary>
public sealed record HighImpactFinding(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string? ComponentPurl,
[property: JsonPropertyName("advisory_id")] string? AdvisoryId,
[property: JsonPropertyName("base_outcome")] string BaseOutcome,
[property: JsonPropertyName("candidate_outcome")] string? CandidateOutcome,
[property: JsonPropertyName("base_severity")] string? BaseSeverity,
[property: JsonPropertyName("candidate_severity")] string? CandidateSeverity,
[property: JsonPropertyName("impact_score")] double ImpactScore,
[property: JsonPropertyName("impact_reason")] string ImpactReason);

View File

@@ -0,0 +1,811 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Service for computing simulation analytics including rule firing counts, heatmaps,
/// sampled traces, and delta summaries.
/// </summary>
public sealed class SimulationAnalyticsService
{
private static readonly ImmutableArray<string> OutcomeSeverityOrder = ImmutableArray.Create(
"allow", "info", "warn", "review", "block", "deny", "critical");
private static readonly ImmutableArray<string> SeverityOrder = ImmutableArray.Create(
"informational", "low", "medium", "high", "critical");
/// <summary>
/// Computes full simulation analytics from rule hit traces.
/// </summary>
public SimulationAnalytics ComputeAnalytics(
string policyRef,
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings,
SimulationAnalyticsOptions? options = null)
{
options ??= SimulationAnalyticsOptions.Default;
var firingCounts = ComputeRuleFiringCounts(traces, findings.Count);
var heatmap = ComputeHeatmap(traces, findings, options);
var sampledTraces = ComputeSampledTraces(traces, findings, options);
return new SimulationAnalytics(
firingCounts,
heatmap,
sampledTraces,
DeltaSummary: null);
}
/// <summary>
/// Computes delta summary comparing base and candidate simulation results.
/// </summary>
public SimulationDeltaSummary ComputeDeltaSummary(
string basePolicyRef,
string candidatePolicyRef,
IReadOnlyList<SimulationFindingResult> baseResults,
IReadOnlyList<SimulationFindingResult> candidateResults,
SimulationComparisonType comparisonType = SimulationComparisonType.VersionCompare)
{
var baseByFinding = baseResults.ToDictionary(r => r.FindingId);
var candidateByFinding = candidateResults.ToDictionary(r => r.FindingId);
var outcomeChanges = ComputeOutcomeChanges(baseByFinding, candidateByFinding);
var severityChanges = ComputeSeverityChanges(baseByFinding, candidateByFinding);
var ruleChanges = ComputeRuleChanges(baseResults, candidateResults);
var highImpact = ComputeHighImpactFindings(baseByFinding, candidateByFinding);
var hashInput = $"{basePolicyRef}:{candidatePolicyRef}:{baseResults.Count}:{candidateResults.Count}";
var determinismHash = ComputeHash(hashInput);
return new SimulationDeltaSummary(
comparisonType,
basePolicyRef,
candidatePolicyRef,
TotalFindings: baseResults.Count,
outcomeChanges,
severityChanges,
ruleChanges,
highImpact,
determinismHash);
}
/// <summary>
/// Computes rule firing counts from traces.
/// </summary>
public RuleFiringCounts ComputeRuleFiringCounts(
IReadOnlyList<RuleHitTrace> traces,
int totalEvaluations)
{
var ruleStats = new Dictionary<string, RuleStats>();
var byPriority = new Dictionary<int, int>();
var byOutcome = new Dictionary<string, int>();
var byCategory = new Dictionary<string, int>();
var vexByVendor = new Dictionary<string, int>();
var vexByStatus = new Dictionary<string, int>();
var vexByJustification = new Dictionary<string, int>();
var totalFired = 0;
var totalVexOverrides = 0;
foreach (var trace in traces)
{
if (!trace.ExpressionResult)
{
continue;
}
totalFired++;
// Rule stats
if (!ruleStats.TryGetValue(trace.RuleName, out var stats))
{
stats = new RuleStats(trace.RuleName, trace.RulePriority, trace.RuleCategory);
ruleStats[trace.RuleName] = stats;
}
stats.FireCount++;
stats.TotalEvaluationUs += trace.EvaluationMicroseconds;
stats.IncrementOutcome(trace.Outcome);
// Priority aggregation
byPriority.TryGetValue(trace.RulePriority, out var priorityCount);
byPriority[trace.RulePriority] = priorityCount + 1;
// Outcome aggregation
byOutcome.TryGetValue(trace.Outcome, out var outcomeCount);
byOutcome[trace.Outcome] = outcomeCount + 1;
// Category aggregation
if (!string.IsNullOrWhiteSpace(trace.RuleCategory))
{
byCategory.TryGetValue(trace.RuleCategory, out var categoryCount);
byCategory[trace.RuleCategory] = categoryCount + 1;
}
// VEX overrides
if (trace.IsVexOverride)
{
totalVexOverrides++;
if (!string.IsNullOrWhiteSpace(trace.VexVendor))
{
vexByVendor.TryGetValue(trace.VexVendor, out var vendorCount);
vexByVendor[trace.VexVendor] = vendorCount + 1;
}
if (!string.IsNullOrWhiteSpace(trace.VexStatus))
{
vexByStatus.TryGetValue(trace.VexStatus, out var statusCount);
vexByStatus[trace.VexStatus] = statusCount + 1;
}
if (!string.IsNullOrWhiteSpace(trace.VexJustification))
{
vexByJustification.TryGetValue(trace.VexJustification, out var justCount);
vexByJustification[trace.VexJustification] = justCount + 1;
}
}
}
// Build rule fire counts
var ruleFireCounts = ruleStats.Values
.Select(s => new RuleFireCount(
s.RuleName,
s.Priority,
s.Category,
s.FireCount,
totalEvaluations > 0 ? (double)s.FireCount / totalEvaluations * 100 : 0,
s.OutcomeCounts.ToImmutableDictionary(),
s.FireCount > 0 ? (double)s.TotalEvaluationUs / s.FireCount : 0))
.ToImmutableDictionary(r => r.RuleName);
var topRules = ruleFireCounts.Values
.OrderByDescending(r => r.FireCount)
.Take(10)
.ToImmutableArray();
var vexOverrides = new VexOverrideCounts(
totalVexOverrides,
vexByVendor.ToImmutableDictionary(),
vexByStatus.ToImmutableDictionary(),
vexByJustification.ToImmutableDictionary());
return new RuleFiringCounts(
totalEvaluations,
totalFired,
ruleFireCounts,
byPriority.ToImmutableDictionary(),
byOutcome.ToImmutableDictionary(),
byCategory.ToImmutableDictionary(),
topRules,
vexOverrides);
}
/// <summary>
/// Computes heatmap aggregates for visualization.
/// </summary>
public SimulationHeatmap ComputeHeatmap(
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings,
SimulationAnalyticsOptions options)
{
var ruleSeverityMatrix = ComputeRuleSeverityMatrix(traces);
var ruleOutcomeMatrix = ComputeRuleOutcomeMatrix(traces);
var findingCoverage = ComputeFindingRuleCoverage(traces, findings);
var temporalDist = ComputeTemporalDistribution(traces, options.TemporalBucketMs);
return new SimulationHeatmap(
ruleSeverityMatrix,
ruleOutcomeMatrix,
findingCoverage,
temporalDist);
}
/// <summary>
/// Computes sampled explain traces with deterministic ordering.
/// </summary>
public SampledExplainTraces ComputeSampledTraces(
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings,
SimulationAnalyticsOptions options)
{
// Group traces by finding
var tracesByFinding = traces
.GroupBy(t => t.ComponentPurl ?? t.AdvisoryId ?? "unknown")
.ToDictionary(g => g.Key, g => g.ToList());
var findingsById = findings.ToDictionary(f => f.FindingId);
// Deterministic ordering by finding_id, then rule_priority
var ordering = new TraceOrdering("finding_id", "rule_priority", "ascending");
// Sample traces deterministically
var sampledList = new List<SampledTrace>();
var totalTraceCount = 0;
foreach (var finding in findings.OrderBy(f => f.FindingId, StringComparer.Ordinal))
{
var key = finding.ComponentPurl ?? finding.AdvisoryId ?? finding.FindingId;
if (!tracesByFinding.TryGetValue(key, out var findingTraces))
{
continue;
}
totalTraceCount += findingTraces.Count;
// Deterministic sampling based on finding_id hash
var sampleHash = ComputeHash(finding.FindingId);
var sampleValue = Math.Abs(sampleHash.GetHashCode()) % 100;
var shouldSample = sampleValue < (int)(options.TraceSampleRate * 100);
if (!shouldSample && sampledList.Count >= options.MaxSampledTraces)
{
continue;
}
// Always sample high-impact findings
var hasFiredRule = findingTraces.Any(t => t.ExpressionResult);
var isHighSeverity = findingTraces.Any(t =>
t.AssignedSeverity?.Equals("critical", StringComparison.OrdinalIgnoreCase) == true ||
t.AssignedSeverity?.Equals("high", StringComparison.OrdinalIgnoreCase) == true);
var hasVexOverride = findingTraces.Any(t => t.IsVexOverride);
var sampleReason = DetermineSampleReason(shouldSample, isHighSeverity, hasVexOverride);
if (!shouldSample && !isHighSeverity && !hasVexOverride)
{
continue;
}
var orderedTraces = findingTraces.OrderBy(t => t.RulePriority).ToList();
var finalTrace = orderedTraces.LastOrDefault(t => t.ExpressionResult) ?? orderedTraces.LastOrDefault();
if (finalTrace == null)
{
continue;
}
var ruleSequence = orderedTraces
.Where(t => t.ExpressionResult)
.Select(t => t.RuleName)
.ToImmutableArray();
sampledList.Add(new SampledTrace(
TraceId: $"{finding.FindingId}:{finalTrace.SpanId}",
FindingId: finding.FindingId,
ComponentPurl: finding.ComponentPurl,
AdvisoryId: finding.AdvisoryId,
FinalOutcome: finalTrace.Outcome,
AssignedSeverity: finalTrace.AssignedSeverity,
RulesEvaluated: findingTraces.Count,
RulesFired: findingTraces.Count(t => t.ExpressionResult),
VexApplied: hasVexOverride,
EvaluationMs: findingTraces.Sum(t => t.EvaluationMicroseconds) / 1000.0,
RuleSequence: ruleSequence,
SampleReason: sampleReason));
if (sampledList.Count >= options.MaxSampledTraces)
{
break;
}
}
// Compute determinism hash from ordered sample
var hashBuilder = new StringBuilder();
foreach (var sample in sampledList.OrderBy(s => s.FindingId, StringComparer.Ordinal))
{
hashBuilder.Append(sample.FindingId);
hashBuilder.Append(':');
hashBuilder.Append(sample.FinalOutcome);
hashBuilder.Append(';');
}
var determinismHash = ComputeHash(hashBuilder.ToString());
return new SampledExplainTraces(
options.TraceSampleRate,
totalTraceCount,
sampledList.Count,
ordering,
sampledList.ToImmutableArray(),
determinismHash);
}
private ImmutableArray<HeatmapCell> ComputeRuleSeverityMatrix(IReadOnlyList<RuleHitTrace> traces)
{
var matrix = new Dictionary<(string rule, string severity), int>();
foreach (var trace in traces.Where(t => t.ExpressionResult && !string.IsNullOrWhiteSpace(t.AssignedSeverity)))
{
var key = (trace.RuleName, trace.AssignedSeverity!);
matrix.TryGetValue(key, out var count);
matrix[key] = count + 1;
}
var maxValue = matrix.Values.DefaultIfEmpty(1).Max();
return matrix
.Select(kvp => new HeatmapCell(
kvp.Key.rule,
kvp.Key.severity,
kvp.Value,
maxValue > 0 ? (double)kvp.Value / maxValue : 0))
.OrderBy(c => c.X, StringComparer.Ordinal)
.ThenBy(c => SeverityOrder.IndexOf(c.Y.ToLowerInvariant()))
.ToImmutableArray();
}
private ImmutableArray<HeatmapCell> ComputeRuleOutcomeMatrix(IReadOnlyList<RuleHitTrace> traces)
{
var matrix = new Dictionary<(string rule, string outcome), int>();
foreach (var trace in traces.Where(t => t.ExpressionResult))
{
var key = (trace.RuleName, trace.Outcome);
matrix.TryGetValue(key, out var count);
matrix[key] = count + 1;
}
var maxValue = matrix.Values.DefaultIfEmpty(1).Max();
return matrix
.Select(kvp => new HeatmapCell(
kvp.Key.rule,
kvp.Key.outcome,
kvp.Value,
maxValue > 0 ? (double)kvp.Value / maxValue : 0))
.OrderBy(c => c.X, StringComparer.Ordinal)
.ThenBy(c => OutcomeSeverityOrder.IndexOf(c.Y.ToLowerInvariant()))
.ToImmutableArray();
}
private FindingRuleCoverage ComputeFindingRuleCoverage(
IReadOnlyList<RuleHitTrace> traces,
IReadOnlyList<SimulationFinding> findings)
{
var rulesThatFired = traces
.Where(t => t.ExpressionResult)
.Select(t => t.RuleName)
.ToHashSet();
var allRules = traces
.Select(t => t.RuleName)
.Distinct()
.ToHashSet();
var rulesNeverFired = allRules.Except(rulesThatFired).ToImmutableArray();
// Group by finding to count matches per finding
var findingMatchCounts = traces
.Where(t => t.ExpressionResult)
.GroupBy(t => t.ComponentPurl ?? t.AdvisoryId ?? "unknown")
.ToDictionary(g => g.Key, g => g.Select(t => t.RuleName).Distinct().Count());
var matchCountDistribution = findingMatchCounts.Values
.GroupBy(c => c)
.ToDictionary(g => g.Key, g => g.Count())
.ToImmutableDictionary();
var findingsMatched = findingMatchCounts.Count;
var findingsUnmatched = findings.Count - findingsMatched;
return new FindingRuleCoverage(
findings.Count,
findingsMatched,
findingsUnmatched,
findings.Count > 0 ? (double)findingsMatched / findings.Count * 100 : 0,
rulesNeverFired,
matchCountDistribution);
}
private ImmutableArray<TemporalBucket> ComputeTemporalDistribution(
IReadOnlyList<RuleHitTrace> traces,
long bucketMs)
{
if (traces.Count == 0)
{
return ImmutableArray<TemporalBucket>.Empty;
}
var minTime = traces.Min(t => t.EvaluationTimestamp);
var maxTime = traces.Max(t => t.EvaluationTimestamp);
var totalMs = (long)(maxTime - minTime).TotalMilliseconds;
if (totalMs <= 0)
{
return ImmutableArray.Create(new TemporalBucket(0, bucketMs, traces.Count, traces.Count(t => t.ExpressionResult)));
}
var buckets = new Dictionary<long, (int evalCount, int fireCount)>();
foreach (var trace in traces)
{
var offsetMs = (long)(trace.EvaluationTimestamp - minTime).TotalMilliseconds;
var bucketStart = (offsetMs / bucketMs) * bucketMs;
buckets.TryGetValue(bucketStart, out var counts);
buckets[bucketStart] = (counts.evalCount + 1, counts.fireCount + (trace.ExpressionResult ? 1 : 0));
}
return buckets
.OrderBy(kvp => kvp.Key)
.Select(kvp => new TemporalBucket(kvp.Key, kvp.Key + bucketMs, kvp.Value.evalCount, kvp.Value.fireCount))
.ToImmutableArray();
}
private OutcomeChangeSummary ComputeOutcomeChanges(
Dictionary<string, SimulationFindingResult> baseResults,
Dictionary<string, SimulationFindingResult> candidateResults)
{
var unchanged = 0;
var improved = 0;
var regressed = 0;
var transitionCounts = new Dictionary<(string from, string to), int>();
foreach (var (findingId, baseResult) in baseResults)
{
if (!candidateResults.TryGetValue(findingId, out var candidateResult))
{
continue;
}
if (baseResult.Outcome == candidateResult.Outcome)
{
unchanged++;
}
else
{
var key = (baseResult.Outcome, candidateResult.Outcome);
transitionCounts.TryGetValue(key, out var count);
transitionCounts[key] = count + 1;
var isImprovement = IsOutcomeImprovement(baseResult.Outcome, candidateResult.Outcome);
if (isImprovement)
{
improved++;
}
else
{
regressed++;
}
}
}
var total = baseResults.Count;
var transitions = transitionCounts
.Select(kvp => new OutcomeTransition(
kvp.Key.from,
kvp.Key.to,
kvp.Value,
total > 0 ? (double)kvp.Value / total * 100 : 0,
IsOutcomeImprovement(kvp.Key.from, kvp.Key.to)))
.OrderByDescending(t => t.Count)
.ToImmutableArray();
return new OutcomeChangeSummary(unchanged, improved, regressed, transitions);
}
private SeverityChangeSummary ComputeSeverityChanges(
Dictionary<string, SimulationFindingResult> baseResults,
Dictionary<string, SimulationFindingResult> candidateResults)
{
var unchanged = 0;
var escalated = 0;
var deescalated = 0;
var transitionCounts = new Dictionary<(string from, string to), int>();
foreach (var (findingId, baseResult) in baseResults)
{
if (!candidateResults.TryGetValue(findingId, out var candidateResult))
{
continue;
}
var baseSeverity = baseResult.Severity ?? "unknown";
var candidateSeverity = candidateResult.Severity ?? "unknown";
if (baseSeverity == candidateSeverity)
{
unchanged++;
}
else
{
var key = (baseSeverity, candidateSeverity);
transitionCounts.TryGetValue(key, out var count);
transitionCounts[key] = count + 1;
var baseIdx = SeverityOrder.IndexOf(baseSeverity.ToLowerInvariant());
var candidateIdx = SeverityOrder.IndexOf(candidateSeverity.ToLowerInvariant());
if (candidateIdx > baseIdx)
{
escalated++;
}
else
{
deescalated++;
}
}
}
var total = baseResults.Count;
var transitions = transitionCounts
.Select(kvp => new SeverityTransition(
kvp.Key.from,
kvp.Key.to,
kvp.Value,
total > 0 ? (double)kvp.Value / total * 100 : 0))
.OrderByDescending(t => t.Count)
.ToImmutableArray();
return new SeverityChangeSummary(unchanged, escalated, deescalated, transitions);
}
private RuleChangeSummary ComputeRuleChanges(
IReadOnlyList<SimulationFindingResult> baseResults,
IReadOnlyList<SimulationFindingResult> candidateResults)
{
var baseRules = baseResults
.SelectMany(r => r.FiredRules ?? Array.Empty<string>())
.Distinct()
.ToHashSet();
var candidateRules = candidateResults
.SelectMany(r => r.FiredRules ?? Array.Empty<string>())
.Distinct()
.ToHashSet();
var rulesAdded = candidateRules.Except(baseRules).ToImmutableArray();
var rulesRemoved = baseRules.Except(candidateRules).ToImmutableArray();
// Compute fire rate changes for common rules
var baseFireRates = ComputeFireRates(baseResults);
var candidateFireRates = ComputeFireRates(candidateResults);
var fireRateChanges = baseRules.Intersect(candidateRules)
.Select(rule =>
{
var baseRate = baseFireRates.GetValueOrDefault(rule, 0);
var candidateRate = candidateFireRates.GetValueOrDefault(rule, 0);
var change = candidateRate - baseRate;
return new RuleFireRateChange(
rule,
baseRate,
candidateRate,
change,
Math.Abs(change) > 5.0); // >5% change is significant
})
.Where(c => Math.Abs(c.ChangePercentage) > 1.0) // Only show changes > 1%
.OrderByDescending(c => Math.Abs(c.ChangePercentage))
.Take(20)
.ToImmutableArray();
return new RuleChangeSummary(
rulesAdded,
rulesRemoved,
ImmutableArray<RuleModification>.Empty, // Would require policy diff analysis
fireRateChanges);
}
private Dictionary<string, double> ComputeFireRates(IReadOnlyList<SimulationFindingResult> results)
{
var ruleCounts = new Dictionary<string, int>();
foreach (var result in results)
{
foreach (var rule in result.FiredRules ?? Array.Empty<string>())
{
ruleCounts.TryGetValue(rule, out var count);
ruleCounts[rule] = count + 1;
}
}
var total = results.Count;
return ruleCounts.ToDictionary(
kvp => kvp.Key,
kvp => total > 0 ? (double)kvp.Value / total * 100 : 0);
}
private ImmutableArray<HighImpactFinding> ComputeHighImpactFindings(
Dictionary<string, SimulationFindingResult> baseResults,
Dictionary<string, SimulationFindingResult> candidateResults)
{
var highImpact = new List<HighImpactFinding>();
foreach (var (findingId, baseResult) in baseResults)
{
if (!candidateResults.TryGetValue(findingId, out var candidateResult))
{
continue;
}
var impactScore = ComputeImpactScore(baseResult, candidateResult);
if (impactScore < 0.3) // Threshold for high impact
{
continue;
}
var impactReason = DetermineImpactReason(baseResult, candidateResult);
highImpact.Add(new HighImpactFinding(
findingId,
baseResult.ComponentPurl,
baseResult.AdvisoryId,
baseResult.Outcome,
candidateResult.Outcome,
baseResult.Severity,
candidateResult.Severity,
impactScore,
impactReason));
}
return highImpact
.OrderByDescending(f => f.ImpactScore)
.Take(50)
.ToImmutableArray();
}
private double ComputeImpactScore(SimulationFindingResult baseResult, SimulationFindingResult candidateResult)
{
var score = 0.0;
// Outcome change weight
if (baseResult.Outcome != candidateResult.Outcome)
{
var baseIdx = OutcomeSeverityOrder.IndexOf(baseResult.Outcome.ToLowerInvariant());
var candidateIdx = OutcomeSeverityOrder.IndexOf(candidateResult.Outcome.ToLowerInvariant());
score += Math.Abs(candidateIdx - baseIdx) * 0.2;
}
// Severity change weight
var baseSeverity = baseResult.Severity ?? "unknown";
var candidateSeverity = candidateResult.Severity ?? "unknown";
if (baseSeverity != candidateSeverity)
{
var baseIdx = SeverityOrder.IndexOf(baseSeverity.ToLowerInvariant());
var candidateIdx = SeverityOrder.IndexOf(candidateSeverity.ToLowerInvariant());
score += Math.Abs(candidateIdx - baseIdx) * 0.15;
}
return Math.Min(1.0, score);
}
private string DetermineImpactReason(SimulationFindingResult baseResult, SimulationFindingResult candidateResult)
{
var reasons = new List<string>();
if (baseResult.Outcome != candidateResult.Outcome)
{
reasons.Add($"Outcome changed from '{baseResult.Outcome}' to '{candidateResult.Outcome}'");
}
if (baseResult.Severity != candidateResult.Severity)
{
reasons.Add($"Severity changed from '{baseResult.Severity}' to '{candidateResult.Severity}'");
}
return string.Join("; ", reasons);
}
private bool IsOutcomeImprovement(string from, string to)
{
var fromIdx = OutcomeSeverityOrder.IndexOf(from.ToLowerInvariant());
var toIdx = OutcomeSeverityOrder.IndexOf(to.ToLowerInvariant());
// Lower index = less severe = improvement
return toIdx < fromIdx;
}
private static string DetermineSampleReason(bool randomSample, bool highSeverity, bool vexOverride)
{
if (vexOverride)
{
return "vex_override";
}
if (highSeverity)
{
return "high_severity";
}
return randomSample ? "random_sample" : "coverage";
}
private static string ComputeHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes)[..16].ToLowerInvariant();
}
private sealed class RuleStats
{
public string RuleName { get; }
public int Priority { get; }
public string? Category { get; }
public int FireCount { get; set; }
public long TotalEvaluationUs { get; set; }
public Dictionary<string, int> OutcomeCounts { get; } = new();
public RuleStats(string ruleName, int priority, string? category)
{
RuleName = ruleName;
Priority = priority;
Category = category;
}
public void IncrementOutcome(string outcome)
{
OutcomeCounts.TryGetValue(outcome, out var count);
OutcomeCounts[outcome] = count + 1;
}
}
}
/// <summary>
/// Options for simulation analytics computation.
/// </summary>
public sealed record SimulationAnalyticsOptions
{
/// <summary>
/// Sample rate for traces (0.0 to 1.0).
/// </summary>
public double TraceSampleRate { get; init; } = 0.1;
/// <summary>
/// Maximum number of sampled traces to include.
/// </summary>
public int MaxSampledTraces { get; init; } = 100;
/// <summary>
/// Temporal bucket size in milliseconds.
/// </summary>
public long TemporalBucketMs { get; init; } = 100;
/// <summary>
/// Maximum number of top rules to include.
/// </summary>
public int MaxTopRules { get; init; } = 10;
/// <summary>
/// Significance threshold for fire rate changes (percentage).
/// </summary>
public double FireRateSignificanceThreshold { get; init; } = 5.0;
/// <summary>
/// Default options.
/// </summary>
public static SimulationAnalyticsOptions Default { get; } = new();
/// <summary>
/// Options for quick simulations (lower sampling, faster).
/// </summary>
public static SimulationAnalyticsOptions Quick { get; } = new()
{
TraceSampleRate = 0.01,
MaxSampledTraces = 20,
TemporalBucketMs = 500
};
/// <summary>
/// Options for batch simulations (balanced).
/// </summary>
public static SimulationAnalyticsOptions Batch { get; } = new()
{
TraceSampleRate = 0.05,
MaxSampledTraces = 50,
TemporalBucketMs = 200
};
}
/// <summary>
/// Result of a single finding simulation (for delta comparison).
/// </summary>
public sealed record SimulationFindingResult(
string FindingId,
string? ComponentPurl,
string? AdvisoryId,
string Outcome,
string? Severity,
IReadOnlyList<string>? FiredRules);

View File

@@ -10,6 +10,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<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" />

View File

@@ -0,0 +1,325 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing an effective finding after policy evaluation.
/// Collection: effective_finding_{policyId}
/// Tenant-scoped with unique constraint on (tenantId, componentPurl, advisoryId).
/// </summary>
[BsonIgnoreExtraElements]
public sealed class EffectiveFindingDocument
{
/// <summary>
/// Unique identifier: sha256:{hash of tenantId|policyId|componentPurl|advisoryId}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier (normalized to lowercase).
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Policy identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// Policy version at time of evaluation.
/// </summary>
[BsonElement("policyVersion")]
public int PolicyVersion { get; set; }
/// <summary>
/// Component PURL from the SBOM.
/// </summary>
[BsonElement("componentPurl")]
public string ComponentPurl { get; set; } = string.Empty;
/// <summary>
/// Component name.
/// </summary>
[BsonElement("componentName")]
public string ComponentName { get; set; } = string.Empty;
/// <summary>
/// Component version.
/// </summary>
[BsonElement("componentVersion")]
public string ComponentVersion { get; set; } = string.Empty;
/// <summary>
/// Package ecosystem (npm, maven, pypi, etc.).
/// </summary>
[BsonElement("ecosystem")]
[BsonIgnoreIfNull]
public string? Ecosystem { get; set; }
/// <summary>
/// Advisory identifier (CVE, GHSA, etc.).
/// </summary>
[BsonElement("advisoryId")]
public string AdvisoryId { get; set; } = string.Empty;
/// <summary>
/// Advisory source.
/// </summary>
[BsonElement("advisorySource")]
public string AdvisorySource { get; set; } = string.Empty;
/// <summary>
/// Vulnerability ID (may differ from advisory ID).
/// </summary>
[BsonElement("vulnerabilityId")]
[BsonIgnoreIfNull]
public string? VulnerabilityId { get; set; }
/// <summary>
/// Policy evaluation status (affected, blocked, suppressed, etc.).
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = string.Empty;
/// <summary>
/// Normalized severity (Critical, High, Medium, Low, None).
/// </summary>
[BsonElement("severity")]
[BsonIgnoreIfNull]
public string? Severity { get; set; }
/// <summary>
/// CVSS score (if available).
/// </summary>
[BsonElement("cvssScore")]
[BsonIgnoreIfNull]
public double? CvssScore { get; set; }
/// <summary>
/// Rule name that matched.
/// </summary>
[BsonElement("ruleName")]
[BsonIgnoreIfNull]
public string? RuleName { get; set; }
/// <summary>
/// Rule priority.
/// </summary>
[BsonElement("rulePriority")]
[BsonIgnoreIfNull]
public int? RulePriority { get; set; }
/// <summary>
/// VEX status overlay (if VEX was applied).
/// </summary>
[BsonElement("vexStatus")]
[BsonIgnoreIfNull]
public string? VexStatus { get; set; }
/// <summary>
/// VEX justification (if VEX was applied).
/// </summary>
[BsonElement("vexJustification")]
[BsonIgnoreIfNull]
public string? VexJustification { get; set; }
/// <summary>
/// VEX provider/vendor.
/// </summary>
[BsonElement("vexVendor")]
[BsonIgnoreIfNull]
public string? VexVendor { get; set; }
/// <summary>
/// Whether a VEX override was applied.
/// </summary>
[BsonElement("isVexOverride")]
public bool IsVexOverride { get; set; }
/// <summary>
/// SBOM ID where component was found.
/// </summary>
[BsonElement("sbomId")]
[BsonIgnoreIfNull]
public string? SbomId { get; set; }
/// <summary>
/// Product key associated with the SBOM.
/// </summary>
[BsonElement("productKey")]
[BsonIgnoreIfNull]
public string? ProductKey { get; set; }
/// <summary>
/// Policy evaluation annotations.
/// </summary>
[BsonElement("annotations")]
public Dictionary<string, string> Annotations { get; set; } = new();
/// <summary>
/// Current history version (incremented on each update).
/// </summary>
[BsonElement("historyVersion")]
public long HistoryVersion { get; set; }
/// <summary>
/// Reference to the policy run that produced this finding.
/// </summary>
[BsonElement("policyRunId")]
[BsonIgnoreIfNull]
public string? PolicyRunId { get; set; }
/// <summary>
/// Trace ID for distributed tracing.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// Span ID for distributed tracing.
/// </summary>
[BsonElement("spanId")]
[BsonIgnoreIfNull]
public string? SpanId { get; set; }
/// <summary>
/// When this finding was first created.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// When this finding was last updated.
/// </summary>
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// Content hash for deduplication and change detection.
/// </summary>
[BsonElement("contentHash")]
public string ContentHash { get; set; } = string.Empty;
}
/// <summary>
/// MongoDB document for effective finding history (append-only).
/// Collection: effective_finding_history_{policyId}
/// </summary>
[BsonIgnoreExtraElements]
public sealed class EffectiveFindingHistoryDocument
{
/// <summary>
/// Unique identifier: {findingId}:v{version}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to the effective finding.
/// </summary>
[BsonElement("findingId")]
public string FindingId { get; set; } = string.Empty;
/// <summary>
/// Policy identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// History version number (monotonically increasing).
/// </summary>
[BsonElement("version")]
public long Version { get; set; }
/// <summary>
/// Type of change (Created, StatusChanged, SeverityChanged, VexApplied, etc.).
/// </summary>
[BsonElement("changeType")]
public string ChangeType { get; set; } = string.Empty;
/// <summary>
/// Previous status (for status changes).
/// </summary>
[BsonElement("previousStatus")]
[BsonIgnoreIfNull]
public string? PreviousStatus { get; set; }
/// <summary>
/// New status.
/// </summary>
[BsonElement("newStatus")]
public string NewStatus { get; set; } = string.Empty;
/// <summary>
/// Previous severity (for severity changes).
/// </summary>
[BsonElement("previousSeverity")]
[BsonIgnoreIfNull]
public string? PreviousSeverity { get; set; }
/// <summary>
/// New severity.
/// </summary>
[BsonElement("newSeverity")]
[BsonIgnoreIfNull]
public string? NewSeverity { get; set; }
/// <summary>
/// Previous content hash.
/// </summary>
[BsonElement("previousContentHash")]
[BsonIgnoreIfNull]
public string? PreviousContentHash { get; set; }
/// <summary>
/// New content hash.
/// </summary>
[BsonElement("newContentHash")]
public string NewContentHash { get; set; } = string.Empty;
/// <summary>
/// Policy run that triggered this change.
/// </summary>
[BsonElement("policyRunId")]
[BsonIgnoreIfNull]
public string? PolicyRunId { get; set; }
/// <summary>
/// Trace ID for distributed tracing.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// When this change occurred.
/// </summary>
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; }
/// <summary>
/// TTL expiration timestamp for automatic cleanup.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Creates the composite ID for a history entry.
/// </summary>
public static string CreateId(string findingId, long version) => $"{findingId}:v{version}";
}

View File

@@ -0,0 +1,157 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document for policy audit log entries.
/// Collection: policy_audit
/// Tracks all policy-related actions for compliance and debugging.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyAuditDocument
{
/// <summary>
/// Unique audit entry identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public ObjectId Id { get; set; }
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Action type (PolicyCreated, PolicyUpdated, RevisionApproved, RunStarted, etc.).
/// </summary>
[BsonElement("action")]
public string Action { get; set; } = string.Empty;
/// <summary>
/// Resource type (Policy, Revision, Bundle, Run, Finding).
/// </summary>
[BsonElement("resourceType")]
public string ResourceType { get; set; } = string.Empty;
/// <summary>
/// Resource identifier.
/// </summary>
[BsonElement("resourceId")]
public string ResourceId { get; set; } = string.Empty;
/// <summary>
/// Actor identifier (user ID or service account).
/// </summary>
[BsonElement("actorId")]
[BsonIgnoreIfNull]
public string? ActorId { get; set; }
/// <summary>
/// Actor type (User, ServiceAccount, System).
/// </summary>
[BsonElement("actorType")]
public string ActorType { get; set; } = "System";
/// <summary>
/// Previous state snapshot (for update actions).
/// </summary>
[BsonElement("previousState")]
[BsonIgnoreIfNull]
public BsonDocument? PreviousState { get; set; }
/// <summary>
/// New state snapshot (for create/update actions).
/// </summary>
[BsonElement("newState")]
[BsonIgnoreIfNull]
public BsonDocument? NewState { get; set; }
/// <summary>
/// Additional context/metadata.
/// </summary>
[BsonElement("metadata")]
public Dictionary<string, string> Metadata { get; set; } = new();
/// <summary>
/// Correlation ID for distributed tracing.
/// </summary>
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
/// <summary>
/// Trace ID for OpenTelemetry.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// Client IP address.
/// </summary>
[BsonElement("clientIp")]
[BsonIgnoreIfNull]
public string? ClientIp { get; set; }
/// <summary>
/// User agent string.
/// </summary>
[BsonElement("userAgent")]
[BsonIgnoreIfNull]
public string? UserAgent { get; set; }
/// <summary>
/// When the action occurred.
/// </summary>
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; }
}
/// <summary>
/// Audit action types for policy operations.
/// </summary>
public static class PolicyAuditActions
{
public const string PolicyCreated = "PolicyCreated";
public const string PolicyUpdated = "PolicyUpdated";
public const string PolicyDeleted = "PolicyDeleted";
public const string RevisionCreated = "RevisionCreated";
public const string RevisionApproved = "RevisionApproved";
public const string RevisionActivated = "RevisionActivated";
public const string RevisionArchived = "RevisionArchived";
public const string BundleCompiled = "BundleCompiled";
public const string RunStarted = "RunStarted";
public const string RunCompleted = "RunCompleted";
public const string RunFailed = "RunFailed";
public const string RunCancelled = "RunCancelled";
public const string FindingCreated = "FindingCreated";
public const string FindingUpdated = "FindingUpdated";
public const string SimulationStarted = "SimulationStarted";
public const string SimulationCompleted = "SimulationCompleted";
}
/// <summary>
/// Resource types for policy audit entries.
/// </summary>
public static class PolicyAuditResourceTypes
{
public const string Policy = "Policy";
public const string Revision = "Revision";
public const string Bundle = "Bundle";
public const string Run = "Run";
public const string Finding = "Finding";
public const string Simulation = "Simulation";
}
/// <summary>
/// Actor types for policy audit entries.
/// </summary>
public static class PolicyAuditActorTypes
{
public const string User = "User";
public const string ServiceAccount = "ServiceAccount";
public const string System = "System";
}

View File

@@ -0,0 +1,343 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing a policy pack.
/// Collection: policies
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyDocument
{
/// <summary>
/// Unique identifier (packId).
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier (normalized to lowercase).
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Display name for the policy pack.
/// </summary>
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
/// <summary>
/// Description of the policy pack.
/// </summary>
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
/// <summary>
/// Current active revision version (null if none active).
/// </summary>
[BsonElement("activeVersion")]
[BsonIgnoreIfNull]
public int? ActiveVersion { get; set; }
/// <summary>
/// Latest revision version.
/// </summary>
[BsonElement("latestVersion")]
public int LatestVersion { get; set; }
/// <summary>
/// Tags for categorization and filtering.
/// </summary>
[BsonElement("tags")]
public List<string> Tags { get; set; } = [];
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Last update timestamp.
/// </summary>
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// User who created the policy pack.
/// </summary>
[BsonElement("createdBy")]
[BsonIgnoreIfNull]
public string? CreatedBy { get; set; }
}
/// <summary>
/// MongoDB document representing a policy revision.
/// Collection: policy_revisions
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRevisionDocument
{
/// <summary>
/// Unique identifier: {packId}:{version}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to policy pack.
/// </summary>
[BsonElement("packId")]
public string PackId { get; set; } = string.Empty;
/// <summary>
/// Revision version number.
/// </summary>
[BsonElement("version")]
public int Version { get; set; }
/// <summary>
/// Revision status (Draft, Approved, Active, Archived).
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "Draft";
/// <summary>
/// Whether two-person approval is required.
/// </summary>
[BsonElement("requiresTwoPersonApproval")]
public bool RequiresTwoPersonApproval { get; set; }
/// <summary>
/// Approval records.
/// </summary>
[BsonElement("approvals")]
public List<PolicyApprovalRecord> Approvals { get; set; } = [];
/// <summary>
/// Reference to the compiled bundle.
/// </summary>
[BsonElement("bundleId")]
[BsonIgnoreIfNull]
public string? BundleId { get; set; }
/// <summary>
/// SHA256 digest of the bundle.
/// </summary>
[BsonElement("bundleDigest")]
[BsonIgnoreIfNull]
public string? BundleDigest { get; set; }
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Activation timestamp (when status became Active).
/// </summary>
[BsonElement("activatedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ActivatedAt { get; set; }
/// <summary>
/// Creates the composite ID for a revision.
/// </summary>
public static string CreateId(string packId, int version) => $"{packId}:{version}";
}
/// <summary>
/// Embedded approval record for policy revisions.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyApprovalRecord
{
/// <summary>
/// User who approved.
/// </summary>
[BsonElement("actorId")]
public string ActorId { get; set; } = string.Empty;
/// <summary>
/// Approval timestamp.
/// </summary>
[BsonElement("approvedAt")]
public DateTimeOffset ApprovedAt { get; set; }
/// <summary>
/// Optional comment.
/// </summary>
[BsonElement("comment")]
[BsonIgnoreIfNull]
public string? Comment { get; set; }
}
/// <summary>
/// MongoDB document for compiled policy bundles.
/// Collection: policy_bundles
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyBundleDocument
{
/// <summary>
/// Unique identifier (SHA256 digest).
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to policy pack.
/// </summary>
[BsonElement("packId")]
public string PackId { get; set; } = string.Empty;
/// <summary>
/// Revision version.
/// </summary>
[BsonElement("version")]
public int Version { get; set; }
/// <summary>
/// Cryptographic signature.
/// </summary>
[BsonElement("signature")]
public string Signature { get; set; } = string.Empty;
/// <summary>
/// Bundle size in bytes.
/// </summary>
[BsonElement("sizeBytes")]
public int SizeBytes { get; set; }
/// <summary>
/// Compiled bundle payload (binary).
/// </summary>
[BsonElement("payload")]
public byte[] Payload { get; set; } = [];
/// <summary>
/// AOC metadata for compliance tracking.
/// </summary>
[BsonElement("aocMetadata")]
[BsonIgnoreIfNull]
public PolicyAocMetadataDocument? AocMetadata { get; set; }
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
}
/// <summary>
/// Embedded AOC metadata document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyAocMetadataDocument
{
[BsonElement("compilationId")]
public string CompilationId { get; set; } = string.Empty;
[BsonElement("compilerVersion")]
public string CompilerVersion { get; set; } = string.Empty;
[BsonElement("compiledAt")]
public DateTimeOffset CompiledAt { get; set; }
[BsonElement("sourceDigest")]
public string SourceDigest { get; set; } = string.Empty;
[BsonElement("artifactDigest")]
public string ArtifactDigest { get; set; } = string.Empty;
[BsonElement("complexityScore")]
public double ComplexityScore { get; set; }
[BsonElement("ruleCount")]
public int RuleCount { get; set; }
[BsonElement("durationMilliseconds")]
public long DurationMilliseconds { get; set; }
[BsonElement("provenance")]
[BsonIgnoreIfNull]
public PolicyProvenanceDocument? Provenance { get; set; }
[BsonElement("attestationRef")]
[BsonIgnoreIfNull]
public PolicyAttestationRefDocument? AttestationRef { get; set; }
}
/// <summary>
/// Embedded provenance document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyProvenanceDocument
{
[BsonElement("sourceType")]
public string SourceType { get; set; } = string.Empty;
[BsonElement("sourceUrl")]
[BsonIgnoreIfNull]
public string? SourceUrl { get; set; }
[BsonElement("submitter")]
[BsonIgnoreIfNull]
public string? Submitter { get; set; }
[BsonElement("commitSha")]
[BsonIgnoreIfNull]
public string? CommitSha { get; set; }
[BsonElement("branch")]
[BsonIgnoreIfNull]
public string? Branch { get; set; }
[BsonElement("ingestedAt")]
public DateTimeOffset IngestedAt { get; set; }
}
/// <summary>
/// Embedded attestation reference document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyAttestationRefDocument
{
[BsonElement("attestationId")]
public string AttestationId { get; set; } = string.Empty;
[BsonElement("envelopeDigest")]
public string EnvelopeDigest { get; set; } = string.Empty;
[BsonElement("uri")]
[BsonIgnoreIfNull]
public string? Uri { get; set; }
[BsonElement("signingKeyId")]
[BsonIgnoreIfNull]
public string? SigningKeyId { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,482 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing a policy exception.
/// Collection: exceptions
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyExceptionDocument
{
/// <summary>
/// Unique identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier (normalized to lowercase).
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Human-readable name for the exception.
/// </summary>
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Description and justification for the exception.
/// </summary>
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
/// <summary>
/// Exception type: waiver, override, temporary, permanent.
/// </summary>
[BsonElement("exceptionType")]
public string ExceptionType { get; set; } = "waiver";
/// <summary>
/// Exception status: draft, pending_review, approved, active, expired, revoked.
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "draft";
/// <summary>
/// Scope of the exception (e.g., advisory IDs, PURL patterns, CVE IDs).
/// </summary>
[BsonElement("scope")]
public ExceptionScopeDocument Scope { get; set; } = new();
/// <summary>
/// Risk assessment and mitigation details.
/// </summary>
[BsonElement("riskAssessment")]
[BsonIgnoreIfNull]
public ExceptionRiskAssessmentDocument? RiskAssessment { get; set; }
/// <summary>
/// Compensating controls in place while exception is active.
/// </summary>
[BsonElement("compensatingControls")]
public List<string> CompensatingControls { get; set; } = [];
/// <summary>
/// Tags for categorization and filtering.
/// </summary>
[BsonElement("tags")]
public List<string> Tags { get; set; } = [];
/// <summary>
/// Priority for conflict resolution (higher = more precedence).
/// </summary>
[BsonElement("priority")]
public int Priority { get; set; }
/// <summary>
/// When the exception becomes active (null = immediately upon approval).
/// </summary>
[BsonElement("effectiveFrom")]
[BsonIgnoreIfNull]
public DateTimeOffset? EffectiveFrom { get; set; }
/// <summary>
/// When the exception expires (null = no expiration).
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// User who created the exception.
/// </summary>
[BsonElement("createdBy")]
public string CreatedBy { get; set; } = string.Empty;
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Last update timestamp.
/// </summary>
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// When the exception was activated.
/// </summary>
[BsonElement("activatedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ActivatedAt { get; set; }
/// <summary>
/// When the exception was revoked.
/// </summary>
[BsonElement("revokedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? RevokedAt { get; set; }
/// <summary>
/// User who revoked the exception.
/// </summary>
[BsonElement("revokedBy")]
[BsonIgnoreIfNull]
public string? RevokedBy { get; set; }
/// <summary>
/// Reason for revocation.
/// </summary>
[BsonElement("revocationReason")]
[BsonIgnoreIfNull]
public string? RevocationReason { get; set; }
/// <summary>
/// Reference to the active review (if pending_review status).
/// </summary>
[BsonElement("activeReviewId")]
[BsonIgnoreIfNull]
public string? ActiveReviewId { get; set; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
}
/// <summary>
/// Embedded document for exception scope definition.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionScopeDocument
{
/// <summary>
/// Advisory IDs covered by this exception.
/// </summary>
[BsonElement("advisoryIds")]
public List<string> AdvisoryIds { get; set; } = [];
/// <summary>
/// CVE IDs covered by this exception.
/// </summary>
[BsonElement("cveIds")]
public List<string> CveIds { get; set; } = [];
/// <summary>
/// PURL patterns (supports wildcards) covered by this exception.
/// </summary>
[BsonElement("purlPatterns")]
public List<string> PurlPatterns { get; set; } = [];
/// <summary>
/// Specific asset IDs covered.
/// </summary>
[BsonElement("assetIds")]
public List<string> AssetIds { get; set; } = [];
/// <summary>
/// Repository IDs covered (scope limiter).
/// </summary>
[BsonElement("repositoryIds")]
public List<string> RepositoryIds { get; set; } = [];
/// <summary>
/// Snapshot IDs covered (scope limiter).
/// </summary>
[BsonElement("snapshotIds")]
public List<string> SnapshotIds { get; set; } = [];
/// <summary>
/// Severity levels to apply exception to.
/// </summary>
[BsonElement("severities")]
public List<string> Severities { get; set; } = [];
/// <summary>
/// Whether this exception applies to all assets (tenant-wide).
/// </summary>
[BsonElement("applyToAll")]
public bool ApplyToAll { get; set; }
}
/// <summary>
/// Embedded document for risk assessment.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionRiskAssessmentDocument
{
/// <summary>
/// Original risk level being excepted.
/// </summary>
[BsonElement("originalRiskLevel")]
public string OriginalRiskLevel { get; set; } = string.Empty;
/// <summary>
/// Residual risk level after compensating controls.
/// </summary>
[BsonElement("residualRiskLevel")]
public string ResidualRiskLevel { get; set; } = string.Empty;
/// <summary>
/// Business justification for accepting the risk.
/// </summary>
[BsonElement("businessJustification")]
[BsonIgnoreIfNull]
public string? BusinessJustification { get; set; }
/// <summary>
/// Impact assessment if vulnerability is exploited.
/// </summary>
[BsonElement("impactAssessment")]
[BsonIgnoreIfNull]
public string? ImpactAssessment { get; set; }
/// <summary>
/// Exploitability assessment.
/// </summary>
[BsonElement("exploitability")]
[BsonIgnoreIfNull]
public string? Exploitability { get; set; }
}
/// <summary>
/// MongoDB document representing an exception review.
/// Collection: exception_reviews
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionReviewDocument
{
/// <summary>
/// Unique identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to the exception being reviewed.
/// </summary>
[BsonElement("exceptionId")]
public string ExceptionId { get; set; } = string.Empty;
/// <summary>
/// Review status: pending, approved, rejected.
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// Type of review: initial, renewal, modification.
/// </summary>
[BsonElement("reviewType")]
public string ReviewType { get; set; } = "initial";
/// <summary>
/// Whether multiple approvers are required.
/// </summary>
[BsonElement("requiresMultipleApprovers")]
public bool RequiresMultipleApprovers { get; set; }
/// <summary>
/// Minimum number of approvals required.
/// </summary>
[BsonElement("requiredApprovals")]
public int RequiredApprovals { get; set; } = 1;
/// <summary>
/// Designated reviewers (user or group IDs).
/// </summary>
[BsonElement("designatedReviewers")]
public List<string> DesignatedReviewers { get; set; } = [];
/// <summary>
/// Individual approval/rejection decisions.
/// </summary>
[BsonElement("decisions")]
public List<ReviewDecisionDocument> Decisions { get; set; } = [];
/// <summary>
/// User who requested the review.
/// </summary>
[BsonElement("requestedBy")]
public string RequestedBy { get; set; } = string.Empty;
/// <summary>
/// When the review was requested.
/// </summary>
[BsonElement("requestedAt")]
public DateTimeOffset RequestedAt { get; set; }
/// <summary>
/// When the review was completed.
/// </summary>
[BsonElement("completedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Review deadline.
/// </summary>
[BsonElement("deadline")]
[BsonIgnoreIfNull]
public DateTimeOffset? Deadline { get; set; }
/// <summary>
/// Notes or comments on the review.
/// </summary>
[BsonElement("notes")]
[BsonIgnoreIfNull]
public string? Notes { get; set; }
/// <summary>
/// Creates the composite ID for a review.
/// </summary>
public static string CreateId(string exceptionId, string reviewType, DateTimeOffset timestamp)
=> $"{exceptionId}:{reviewType}:{timestamp:yyyyMMddHHmmss}";
}
/// <summary>
/// Embedded document for an individual reviewer's decision.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ReviewDecisionDocument
{
/// <summary>
/// Reviewer ID (user or service account).
/// </summary>
[BsonElement("reviewerId")]
public string ReviewerId { get; set; } = string.Empty;
/// <summary>
/// Decision: approved, rejected, abstained.
/// </summary>
[BsonElement("decision")]
public string Decision { get; set; } = string.Empty;
/// <summary>
/// Timestamp of the decision.
/// </summary>
[BsonElement("decidedAt")]
public DateTimeOffset DecidedAt { get; set; }
/// <summary>
/// Comment explaining the decision.
/// </summary>
[BsonElement("comment")]
[BsonIgnoreIfNull]
public string? Comment { get; set; }
/// <summary>
/// Conditions attached to approval.
/// </summary>
[BsonElement("conditions")]
public List<string> Conditions { get; set; } = [];
}
/// <summary>
/// MongoDB document representing an exception binding to specific assets.
/// Collection: exception_bindings
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExceptionBindingDocument
{
/// <summary>
/// Unique identifier: {exceptionId}:{assetId}:{advisoryId}
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Reference to the exception.
/// </summary>
[BsonElement("exceptionId")]
public string ExceptionId { get; set; } = string.Empty;
/// <summary>
/// Asset ID (PURL or other identifier) this binding applies to.
/// </summary>
[BsonElement("assetId")]
public string AssetId { get; set; } = string.Empty;
/// <summary>
/// Advisory ID this binding covers.
/// </summary>
[BsonElement("advisoryId")]
[BsonIgnoreIfNull]
public string? AdvisoryId { get; set; }
/// <summary>
/// CVE ID this binding covers.
/// </summary>
[BsonElement("cveId")]
[BsonIgnoreIfNull]
public string? CveId { get; set; }
/// <summary>
/// Snapshot ID where binding was created.
/// </summary>
[BsonElement("snapshotId")]
[BsonIgnoreIfNull]
public string? SnapshotId { get; set; }
/// <summary>
/// Binding status: active, expired, revoked.
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "active";
/// <summary>
/// Policy decision override applied by this binding.
/// </summary>
[BsonElement("decisionOverride")]
public string DecisionOverride { get; set; } = "allow";
/// <summary>
/// When the binding becomes effective.
/// </summary>
[BsonElement("effectiveFrom")]
public DateTimeOffset EffectiveFrom { get; set; }
/// <summary>
/// When the binding expires.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// When the binding was created.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Creates the composite ID for a binding.
/// </summary>
public static string CreateId(string exceptionId, string assetId, string? advisoryId)
=> $"{exceptionId}:{assetId}:{advisoryId ?? "all"}";
}

View File

@@ -0,0 +1,383 @@
using System.Collections.Immutable;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document for storing policy explain traces.
/// Collection: policy_explains
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyExplainDocument
{
/// <summary>
/// Unique identifier (combination of runId and subjectHash).
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Policy run identifier.
/// </summary>
[BsonElement("runId")]
public string RunId { get; set; } = string.Empty;
/// <summary>
/// Policy pack identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// Policy version at time of evaluation.
/// </summary>
[BsonElement("policyVersion")]
[BsonIgnoreIfNull]
public int? PolicyVersion { get; set; }
/// <summary>
/// Hash of the evaluation subject (component + advisory).
/// </summary>
[BsonElement("subjectHash")]
public string SubjectHash { get; set; } = string.Empty;
/// <summary>
/// Hash of the policy bundle used.
/// </summary>
[BsonElement("bundleDigest")]
[BsonIgnoreIfNull]
public string? BundleDigest { get; set; }
/// <summary>
/// Evaluation timestamp (deterministic).
/// </summary>
[BsonElement("evaluatedAt")]
public DateTimeOffset EvaluatedAt { get; set; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
[BsonElement("durationMs")]
public long DurationMs { get; set; }
/// <summary>
/// Final outcome of the evaluation.
/// </summary>
[BsonElement("finalOutcome")]
public string FinalOutcome { get; set; } = string.Empty;
/// <summary>
/// Input context information.
/// </summary>
[BsonElement("inputContext")]
public ExplainInputContextDocument InputContext { get; set; } = new();
/// <summary>
/// Rule evaluation steps.
/// </summary>
[BsonElement("ruleSteps")]
public List<ExplainRuleStepDocument> RuleSteps { get; set; } = [];
/// <summary>
/// VEX evidence applied.
/// </summary>
[BsonElement("vexEvidence")]
public List<ExplainVexEvidenceDocument> VexEvidence { get; set; } = [];
/// <summary>
/// Statistics summary.
/// </summary>
[BsonElement("statistics")]
public ExplainStatisticsDocument Statistics { get; set; } = new();
/// <summary>
/// Determinism hash for reproducibility verification.
/// </summary>
[BsonElement("determinismHash")]
[BsonIgnoreIfNull]
public string? DeterminismHash { get; set; }
/// <summary>
/// Reference to AOC chain for this evaluation.
/// </summary>
[BsonElement("aocChain")]
[BsonIgnoreIfNull]
public ExplainAocChainDocument? AocChain { get; set; }
/// <summary>
/// Additional metadata.
/// </summary>
[BsonElement("metadata")]
public Dictionary<string, string> Metadata { get; set; } = new();
/// <summary>
/// Creation timestamp.
/// </summary>
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// TTL expiration timestamp for automatic cleanup.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Creates the composite ID for an explain trace.
/// </summary>
public static string CreateId(string runId, string subjectHash) => $"{runId}:{subjectHash}";
}
/// <summary>
/// Input context embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainInputContextDocument
{
[BsonElement("componentPurl")]
[BsonIgnoreIfNull]
public string? ComponentPurl { get; set; }
[BsonElement("componentName")]
[BsonIgnoreIfNull]
public string? ComponentName { get; set; }
[BsonElement("componentVersion")]
[BsonIgnoreIfNull]
public string? ComponentVersion { get; set; }
[BsonElement("advisoryId")]
[BsonIgnoreIfNull]
public string? AdvisoryId { get; set; }
[BsonElement("vulnerabilityId")]
[BsonIgnoreIfNull]
public string? VulnerabilityId { get; set; }
[BsonElement("inputSeverity")]
[BsonIgnoreIfNull]
public string? InputSeverity { get; set; }
[BsonElement("inputCvssScore")]
[BsonIgnoreIfNull]
public decimal? InputCvssScore { get; set; }
[BsonElement("environment")]
public Dictionary<string, string> Environment { get; set; } = new();
[BsonElement("sbomTags")]
public List<string> SbomTags { get; set; } = [];
[BsonElement("reachabilityState")]
[BsonIgnoreIfNull]
public string? ReachabilityState { get; set; }
[BsonElement("reachabilityConfidence")]
[BsonIgnoreIfNull]
public double? ReachabilityConfidence { get; set; }
}
/// <summary>
/// Rule step embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainRuleStepDocument
{
[BsonElement("stepNumber")]
public int StepNumber { get; set; }
[BsonElement("ruleName")]
public string RuleName { get; set; } = string.Empty;
[BsonElement("rulePriority")]
public int RulePriority { get; set; }
[BsonElement("ruleCategory")]
[BsonIgnoreIfNull]
public string? RuleCategory { get; set; }
[BsonElement("expression")]
[BsonIgnoreIfNull]
public string? Expression { get; set; }
[BsonElement("matched")]
public bool Matched { get; set; }
[BsonElement("outcome")]
[BsonIgnoreIfNull]
public string? Outcome { get; set; }
[BsonElement("assignedSeverity")]
[BsonIgnoreIfNull]
public string? AssignedSeverity { get; set; }
[BsonElement("isFinalMatch")]
public bool IsFinalMatch { get; set; }
[BsonElement("explanation")]
[BsonIgnoreIfNull]
public string? Explanation { get; set; }
[BsonElement("evaluationMicroseconds")]
public long EvaluationMicroseconds { get; set; }
[BsonElement("intermediateValues")]
public Dictionary<string, string> IntermediateValues { get; set; } = new();
}
/// <summary>
/// VEX evidence embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainVexEvidenceDocument
{
[BsonElement("vendor")]
public string Vendor { get; set; } = string.Empty;
[BsonElement("status")]
public string Status { get; set; } = string.Empty;
[BsonElement("justification")]
[BsonIgnoreIfNull]
public string? Justification { get; set; }
[BsonElement("confidence")]
[BsonIgnoreIfNull]
public double? Confidence { get; set; }
[BsonElement("wasApplied")]
public bool WasApplied { get; set; }
[BsonElement("explanation")]
[BsonIgnoreIfNull]
public string? Explanation { get; set; }
}
/// <summary>
/// Statistics embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainStatisticsDocument
{
[BsonElement("totalRulesEvaluated")]
public int TotalRulesEvaluated { get; set; }
[BsonElement("totalRulesFired")]
public int TotalRulesFired { get; set; }
[BsonElement("totalVexOverrides")]
public int TotalVexOverrides { get; set; }
[BsonElement("totalEvaluationMs")]
public long TotalEvaluationMs { get; set; }
[BsonElement("averageRuleEvaluationMicroseconds")]
public double AverageRuleEvaluationMicroseconds { get; set; }
[BsonElement("rulesFiredByCategory")]
public Dictionary<string, int> RulesFiredByCategory { get; set; } = new();
[BsonElement("rulesFiredByOutcome")]
public Dictionary<string, int> RulesFiredByOutcome { get; set; } = new();
}
/// <summary>
/// AOC chain reference for linking decisions to attestations.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainAocChainDocument
{
/// <summary>
/// Compilation ID that produced the policy bundle.
/// </summary>
[BsonElement("compilationId")]
public string CompilationId { get; set; } = string.Empty;
/// <summary>
/// Compiler version used.
/// </summary>
[BsonElement("compilerVersion")]
public string CompilerVersion { get; set; } = string.Empty;
/// <summary>
/// Source digest of the policy document.
/// </summary>
[BsonElement("sourceDigest")]
public string SourceDigest { get; set; } = string.Empty;
/// <summary>
/// Artifact digest of the compiled bundle.
/// </summary>
[BsonElement("artifactDigest")]
public string ArtifactDigest { get; set; } = string.Empty;
/// <summary>
/// Reference to the signed attestation.
/// </summary>
[BsonElement("attestationRef")]
[BsonIgnoreIfNull]
public ExplainAttestationRefDocument? AttestationRef { get; set; }
/// <summary>
/// Provenance information.
/// </summary>
[BsonElement("provenance")]
[BsonIgnoreIfNull]
public ExplainProvenanceDocument? Provenance { get; set; }
}
/// <summary>
/// Attestation reference embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainAttestationRefDocument
{
[BsonElement("attestationId")]
public string AttestationId { get; set; } = string.Empty;
[BsonElement("envelopeDigest")]
public string EnvelopeDigest { get; set; } = string.Empty;
[BsonElement("uri")]
[BsonIgnoreIfNull]
public string? Uri { get; set; }
[BsonElement("signingKeyId")]
[BsonIgnoreIfNull]
public string? SigningKeyId { get; set; }
}
/// <summary>
/// Provenance embedded document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class ExplainProvenanceDocument
{
[BsonElement("sourceType")]
public string SourceType { get; set; } = string.Empty;
[BsonElement("sourceUrl")]
[BsonIgnoreIfNull]
public string? SourceUrl { get; set; }
[BsonElement("submitter")]
[BsonIgnoreIfNull]
public string? Submitter { get; set; }
[BsonElement("commitSha")]
[BsonIgnoreIfNull]
public string? CommitSha { get; set; }
[BsonElement("branch")]
[BsonIgnoreIfNull]
public string? Branch { get; set; }
}

View File

@@ -0,0 +1,319 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
/// <summary>
/// MongoDB document representing a policy evaluation run.
/// Collection: policy_runs
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunDocument
{
/// <summary>
/// Unique run identifier.
/// </summary>
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Tenant identifier.
/// </summary>
[BsonElement("tenantId")]
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Policy pack identifier.
/// </summary>
[BsonElement("policyId")]
public string PolicyId { get; set; } = string.Empty;
/// <summary>
/// Policy version evaluated.
/// </summary>
[BsonElement("policyVersion")]
public int PolicyVersion { get; set; }
/// <summary>
/// Run mode (full, incremental, simulation, batch).
/// </summary>
[BsonElement("mode")]
public string Mode { get; set; } = "full";
/// <summary>
/// Run status (pending, running, completed, failed, cancelled).
/// </summary>
[BsonElement("status")]
public string Status { get; set; } = "pending";
/// <summary>
/// Trigger type (scheduled, manual, event, api).
/// </summary>
[BsonElement("triggerType")]
public string TriggerType { get; set; } = "manual";
/// <summary>
/// Correlation ID for distributed tracing.
/// </summary>
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
/// <summary>
/// Trace ID for OpenTelemetry.
/// </summary>
[BsonElement("traceId")]
[BsonIgnoreIfNull]
public string? TraceId { get; set; }
/// <summary>
/// Parent span ID if part of larger operation.
/// </summary>
[BsonElement("parentSpanId")]
[BsonIgnoreIfNull]
public string? ParentSpanId { get; set; }
/// <summary>
/// User or service that initiated the run.
/// </summary>
[BsonElement("initiatedBy")]
[BsonIgnoreIfNull]
public string? InitiatedBy { get; set; }
/// <summary>
/// Deterministic evaluation timestamp used for this run.
/// </summary>
[BsonElement("evaluationTimestamp")]
public DateTimeOffset EvaluationTimestamp { get; set; }
/// <summary>
/// When the run started.
/// </summary>
[BsonElement("startedAt")]
public DateTimeOffset StartedAt { get; set; }
/// <summary>
/// When the run completed (null if still running).
/// </summary>
[BsonElement("completedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Run metrics and statistics.
/// </summary>
[BsonElement("metrics")]
public PolicyRunMetricsDocument Metrics { get; set; } = new();
/// <summary>
/// Input parameters for the run.
/// </summary>
[BsonElement("input")]
[BsonIgnoreIfNull]
public PolicyRunInputDocument? Input { get; set; }
/// <summary>
/// Run outcome summary.
/// </summary>
[BsonElement("outcome")]
[BsonIgnoreIfNull]
public PolicyRunOutcomeDocument? Outcome { get; set; }
/// <summary>
/// Error information if run failed.
/// </summary>
[BsonElement("error")]
[BsonIgnoreIfNull]
public PolicyRunErrorDocument? Error { get; set; }
/// <summary>
/// Determinism hash for reproducibility verification.
/// </summary>
[BsonElement("determinismHash")]
[BsonIgnoreIfNull]
public string? DeterminismHash { get; set; }
/// <summary>
/// TTL expiration timestamp for automatic cleanup.
/// </summary>
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
}
/// <summary>
/// Embedded metrics document for policy runs.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunMetricsDocument
{
/// <summary>
/// Total components evaluated.
/// </summary>
[BsonElement("totalComponents")]
public int TotalComponents { get; set; }
/// <summary>
/// Total advisories evaluated.
/// </summary>
[BsonElement("totalAdvisories")]
public int TotalAdvisories { get; set; }
/// <summary>
/// Total findings generated.
/// </summary>
[BsonElement("totalFindings")]
public int TotalFindings { get; set; }
/// <summary>
/// Rules evaluated count.
/// </summary>
[BsonElement("rulesEvaluated")]
public int RulesEvaluated { get; set; }
/// <summary>
/// Rules that matched/fired.
/// </summary>
[BsonElement("rulesFired")]
public int RulesFired { get; set; }
/// <summary>
/// VEX overrides applied.
/// </summary>
[BsonElement("vexOverridesApplied")]
public int VexOverridesApplied { get; set; }
/// <summary>
/// Findings created (new).
/// </summary>
[BsonElement("findingsCreated")]
public int FindingsCreated { get; set; }
/// <summary>
/// Findings updated (changed).
/// </summary>
[BsonElement("findingsUpdated")]
public int FindingsUpdated { get; set; }
/// <summary>
/// Findings unchanged.
/// </summary>
[BsonElement("findingsUnchanged")]
public int FindingsUnchanged { get; set; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
[BsonElement("durationMs")]
public long DurationMs { get; set; }
/// <summary>
/// Memory used in bytes.
/// </summary>
[BsonElement("memoryUsedBytes")]
public long MemoryUsedBytes { get; set; }
}
/// <summary>
/// Embedded input parameters document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunInputDocument
{
/// <summary>
/// SBOM IDs included in evaluation.
/// </summary>
[BsonElement("sbomIds")]
public List<string> SbomIds { get; set; } = [];
/// <summary>
/// Product keys included in evaluation.
/// </summary>
[BsonElement("productKeys")]
public List<string> ProductKeys { get; set; } = [];
/// <summary>
/// Advisory IDs to evaluate (empty = all).
/// </summary>
[BsonElement("advisoryIds")]
public List<string> AdvisoryIds { get; set; } = [];
/// <summary>
/// Filter criteria applied.
/// </summary>
[BsonElement("filters")]
[BsonIgnoreIfNull]
public Dictionary<string, string>? Filters { get; set; }
}
/// <summary>
/// Embedded outcome summary document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunOutcomeDocument
{
/// <summary>
/// Overall outcome (pass, fail, warn).
/// </summary>
[BsonElement("result")]
public string Result { get; set; } = "pass";
/// <summary>
/// Findings by severity.
/// </summary>
[BsonElement("bySeverity")]
public Dictionary<string, int> BySeverity { get; set; } = new();
/// <summary>
/// Findings by status.
/// </summary>
[BsonElement("byStatus")]
public Dictionary<string, int> ByStatus { get; set; } = new();
/// <summary>
/// Blocking findings count.
/// </summary>
[BsonElement("blockingCount")]
public int BlockingCount { get; set; }
/// <summary>
/// Summary message.
/// </summary>
[BsonElement("message")]
[BsonIgnoreIfNull]
public string? Message { get; set; }
}
/// <summary>
/// Embedded error document.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class PolicyRunErrorDocument
{
/// <summary>
/// Error code.
/// </summary>
[BsonElement("code")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Error message.
/// </summary>
[BsonElement("message")]
public string Message { get; set; } = string.Empty;
/// <summary>
/// Stack trace (if available).
/// </summary>
[BsonElement("stackTrace")]
[BsonIgnoreIfNull]
public string? StackTrace { get; set; }
/// <summary>
/// Inner error details.
/// </summary>
[BsonElement("innerError")]
[BsonIgnoreIfNull]
public string? InnerError { get; set; }
}

View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Options;
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
/// <summary>
/// MongoDB context for Policy Engine storage operations.
/// Provides configured access to the database with appropriate read/write concerns.
/// </summary>
internal sealed class PolicyEngineMongoContext
{
public PolicyEngineMongoContext(IOptions<PolicyEngineMongoOptions> options, ILogger<PolicyEngineMongoContext> logger)
{
ArgumentNullException.ThrowIfNull(logger);
var value = options?.Value ?? throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(value.ConnectionString))
{
throw new InvalidOperationException("Policy Engine Mongo connection string is not configured.");
}
if (string.IsNullOrWhiteSpace(value.Database))
{
throw new InvalidOperationException("Policy Engine Mongo database name is not configured.");
}
Client = new MongoClient(value.ConnectionString);
var settings = new MongoDatabaseSettings();
if (value.UseMajorityReadConcern)
{
settings.ReadConcern = ReadConcern.Majority;
}
if (value.UseMajorityWriteConcern)
{
settings.WriteConcern = WriteConcern.WMajority;
}
Database = Client.GetDatabase(value.Database, settings);
Options = value;
}
/// <summary>
/// MongoDB client instance.
/// </summary>
public MongoClient Client { get; }
/// <summary>
/// MongoDB database instance with configured read/write concerns.
/// </summary>
public IMongoDatabase Database { get; }
/// <summary>
/// Policy Engine MongoDB options.
/// </summary>
public PolicyEngineMongoOptions Options { get; }
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Storage.Mongo.Migrations;
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
/// <summary>
/// Interface for Policy Engine MongoDB initialization.
/// </summary>
internal interface IPolicyEngineMongoInitializer
{
/// <summary>
/// Ensures all migrations are applied to the database.
/// </summary>
Task EnsureMigrationsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Initializes Policy Engine MongoDB storage by applying migrations.
/// </summary>
internal sealed class PolicyEngineMongoInitializer : IPolicyEngineMongoInitializer
{
private readonly PolicyEngineMongoContext _context;
private readonly PolicyEngineMigrationRunner _migrationRunner;
private readonly ILogger<PolicyEngineMongoInitializer> _logger;
public PolicyEngineMongoInitializer(
PolicyEngineMongoContext context,
PolicyEngineMigrationRunner migrationRunner,
ILogger<PolicyEngineMongoInitializer> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task EnsureMigrationsAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Ensuring Policy Engine Mongo migrations are applied for database {Database}.",
_context.Options.Database);
await _migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,69 @@
using MongoDB.Driver;
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
/// <summary>
/// Builds tenant-scoped filters for Policy Engine MongoDB queries.
/// Ensures all queries are properly scoped to the current tenant.
/// </summary>
internal static class TenantFilterBuilder
{
/// <summary>
/// Creates a filter that matches documents for the specified tenant.
/// </summary>
/// <typeparam name="TDocument">Document type with tenantId field.</typeparam>
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
/// <returns>A filter definition scoped to the tenant.</returns>
public static FilterDefinition<TDocument> ForTenant<TDocument>(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var normalizedTenantId = tenantId.ToLowerInvariant();
return Builders<TDocument>.Filter.Eq("tenantId", normalizedTenantId);
}
/// <summary>
/// Combines a tenant filter with an additional filter using AND.
/// </summary>
/// <typeparam name="TDocument">Document type with tenantId field.</typeparam>
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
/// <param name="additionalFilter">Additional filter to combine.</param>
/// <returns>A combined filter definition.</returns>
public static FilterDefinition<TDocument> ForTenantAnd<TDocument>(
string tenantId,
FilterDefinition<TDocument> additionalFilter)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(additionalFilter);
var tenantFilter = ForTenant<TDocument>(tenantId);
return Builders<TDocument>.Filter.And(tenantFilter, additionalFilter);
}
/// <summary>
/// Creates a filter that matches documents by ID within a tenant scope.
/// </summary>
/// <typeparam name="TDocument">Document type with tenantId and _id fields.</typeparam>
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
/// <param name="documentId">Document identifier.</param>
/// <returns>A filter definition matching both tenant and ID.</returns>
public static FilterDefinition<TDocument> ForTenantById<TDocument>(string tenantId, string documentId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
var tenantFilter = ForTenant<TDocument>(tenantId);
var idFilter = Builders<TDocument>.Filter.Eq("_id", documentId);
return Builders<TDocument>.Filter.And(tenantFilter, idFilter);
}
/// <summary>
/// Normalizes a tenant ID to lowercase for consistent storage and queries.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <returns>Normalized (lowercase) tenant identifier.</returns>
public static string NormalizeTenantId(string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return tenantId.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,283 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Initializes effective_finding_* and effective_finding_history_* collections for a policy.
/// Creates collections and indexes on-demand when a policy is first evaluated.
/// </summary>
internal interface IEffectiveFindingCollectionInitializer
{
/// <summary>
/// Ensures the effective finding collection and indexes exist for a policy.
/// </summary>
/// <param name="policyId">The policy identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask EnsureCollectionAsync(string policyId, CancellationToken cancellationToken);
}
/// <inheritdoc />
internal sealed class EffectiveFindingCollectionInitializer : IEffectiveFindingCollectionInitializer
{
private readonly PolicyEngineMongoContext _context;
private readonly ILogger<EffectiveFindingCollectionInitializer> _logger;
private readonly HashSet<string> _initializedCollections = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _lock = new(1, 1);
public EffectiveFindingCollectionInitializer(
PolicyEngineMongoContext context,
ILogger<EffectiveFindingCollectionInitializer> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async ValueTask EnsureCollectionAsync(string policyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
var findingsCollectionName = _context.Options.GetEffectiveFindingsCollectionName(policyId);
var historyCollectionName = _context.Options.GetEffectiveFindingsHistoryCollectionName(policyId);
// Fast path: already initialized in memory
if (_initializedCollections.Contains(findingsCollectionName))
{
return;
}
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Double-check after acquiring lock
if (_initializedCollections.Contains(findingsCollectionName))
{
return;
}
await EnsureEffectiveFindingCollectionAsync(findingsCollectionName, cancellationToken).ConfigureAwait(false);
await EnsureEffectiveFindingHistoryCollectionAsync(historyCollectionName, cancellationToken).ConfigureAwait(false);
_initializedCollections.Add(findingsCollectionName);
}
finally
{
_lock.Release();
}
}
private async Task EnsureEffectiveFindingCollectionAsync(string collectionName, CancellationToken cancellationToken)
{
var cursor = await _context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
if (!existing.Contains(collectionName, StringComparer.Ordinal))
{
_logger.LogInformation("Creating effective finding collection '{CollectionName}'.", collectionName);
await _context.Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken).ConfigureAwait(false);
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
// Unique constraint on (tenantId, componentPurl, advisoryId)
var tenantComponentAdvisory = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("componentPurl")
.Ascending("advisoryId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_component_advisory_unique",
Unique = true
});
// Tenant + severity for filtering by risk level
var tenantSeverity = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("severity")
.Descending("updatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_severity_updatedAt_desc"
});
// Tenant + status for filtering by policy status
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Descending("updatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_updatedAt_desc"
});
// Product key lookup for SBOM-based queries
var tenantProduct = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("productKey"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_product",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("productKey", true)
});
// SBOM ID lookup
var tenantSbom = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("sbomId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_sbom",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("sbomId", true)
});
// Component name lookup for search
var tenantComponentName = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("componentName"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_componentName"
});
// Advisory ID lookup for cross-policy queries
var tenantAdvisory = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("advisoryId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_advisory"
});
// Policy run reference for traceability
var policyRun = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("policyRunId"),
new CreateIndexOptions<BsonDocument>
{
Name = "policyRun_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("policyRunId", true)
});
// Content hash for deduplication checks
var contentHash = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("contentHash"),
new CreateIndexOptions<BsonDocument>
{
Name = "contentHash_lookup"
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantComponentAdvisory,
tenantSeverity,
tenantStatus,
tenantProduct,
tenantSbom,
tenantComponentName,
tenantAdvisory,
policyRun,
contentHash
},
cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created indexes for effective finding collection '{CollectionName}'.", collectionName);
}
private async Task EnsureEffectiveFindingHistoryCollectionAsync(string collectionName, CancellationToken cancellationToken)
{
var cursor = await _context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
if (!existing.Contains(collectionName, StringComparer.Ordinal))
{
_logger.LogInformation("Creating effective finding history collection '{CollectionName}'.", collectionName);
await _context.Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken).ConfigureAwait(false);
}
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
// Finding + version for retrieving history
var findingVersion = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("findingId")
.Descending("version"),
new CreateIndexOptions<BsonDocument>
{
Name = "finding_version_desc"
});
// Tenant + occurred for chronological history
var tenantOccurred = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("occurredAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_occurredAt_desc"
});
// Change type lookup for filtering history events
var tenantChangeType = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("changeType"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_changeType"
});
// Policy run reference
var policyRun = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("policyRunId"),
new CreateIndexOptions<BsonDocument>
{
Name = "policyRun_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("policyRunId", true)
});
var models = new List<CreateIndexModel<BsonDocument>>
{
findingVersion,
tenantOccurred,
tenantChangeType,
policyRun
};
// TTL index for automatic cleanup of old history entries
if (_context.Options.EffectiveFindingsHistoryRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
models.Add(ttlModel);
}
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created indexes for effective finding history collection '{CollectionName}'.", collectionName);
}
}

View File

@@ -0,0 +1,345 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Migration to ensure all required indexes exist for exception collections.
/// Creates indexes for efficient tenant-scoped queries and status lookups.
/// </summary>
internal sealed class EnsureExceptionIndexesMigration : IPolicyEngineMongoMigration
{
/// <inheritdoc />
public string Id => "20251128_exception_indexes_v1";
/// <inheritdoc />
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
await EnsureExceptionsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureExceptionReviewsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureExceptionBindingsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the exceptions collection.
/// </summary>
private static async Task EnsureExceptionsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionsCollection);
// Tenant + status for finding active/pending exceptions
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Tenant + type + status for filtering
var tenantTypeStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("exceptionType")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_type_status"
});
// Tenant + created descending for recent exceptions
var tenantCreated = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("createdAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_createdAt_desc"
});
// Tenant + tags for filtering by tag
var tenantTags = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("tags"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_tags"
});
// Tenant + expiresAt for finding expiring exceptions
var tenantExpires = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_expiresAt",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("expiresAt", true)
});
// Tenant + effectiveFrom for finding pending activations
var tenantEffectiveFrom = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("effectiveFrom"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_effectiveFrom",
PartialFilterExpression = Builders<BsonDocument>.Filter.Eq("status", "approved")
});
// Scope advisory IDs for finding applicable exceptions
var scopeAdvisoryIds = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("scope.advisoryIds"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_scope_advisoryIds"
});
// Scope asset IDs for finding applicable exceptions
var scopeAssetIds = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("scope.assetIds"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_scope_assetIds"
});
// Scope CVE IDs for finding applicable exceptions
var scopeCveIds = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("scope.cveIds"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_scope_cveIds"
});
// CreatedBy for audit queries
var tenantCreatedBy = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("createdBy"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_createdBy"
});
// Priority for ordering applicable exceptions
var tenantPriority = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Descending("priority"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_priority_desc"
});
// Correlation ID for tracing
var correlationId = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("correlationId"),
new CreateIndexOptions<BsonDocument>
{
Name = "correlationId_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("correlationId", true)
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantStatus,
tenantTypeStatus,
tenantCreated,
tenantTags,
tenantExpires,
tenantEffectiveFrom,
scopeAdvisoryIds,
scopeAssetIds,
scopeCveIds,
tenantCreatedBy,
tenantPriority,
correlationId
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the exception_reviews collection.
/// </summary>
private static async Task EnsureExceptionReviewsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionReviewsCollection);
// Tenant + exception for finding reviews of an exception
var tenantException = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("exceptionId")
.Descending("requestedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_exceptionId_requestedAt_desc"
});
// Tenant + status for finding pending reviews
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Tenant + designated reviewers for reviewer's queue
var tenantReviewers = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("designatedReviewers"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_designatedReviewers"
});
// Deadline for finding overdue reviews
var tenantDeadline = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("deadline"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_deadline",
PartialFilterExpression = Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("status", "pending"),
Builders<BsonDocument>.Filter.Exists("deadline", true))
});
// RequestedBy for audit queries
var tenantRequestedBy = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("requestedBy"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_requestedBy"
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantException,
tenantStatus,
tenantReviewers,
tenantDeadline,
tenantRequestedBy
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the exception_bindings collection.
/// </summary>
private static async Task EnsureExceptionBindingsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionBindingsCollection);
// Tenant + exception for finding bindings of an exception
var tenantException = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("exceptionId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_exceptionId"
});
// Tenant + asset for finding bindings for an asset
var tenantAsset = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("assetId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_assetId_status"
});
// Tenant + advisory for finding bindings by advisory
var tenantAdvisory = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("advisoryId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_advisoryId_status",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("advisoryId", true)
});
// Tenant + CVE for finding bindings by CVE
var tenantCve = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("cveId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_cveId_status",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("cveId", true)
});
// Tenant + status + expiresAt for finding expired bindings
var tenantExpires = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status_expiresAt",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("expiresAt", true)
});
// Effective time range for finding active bindings at a point in time
var tenantEffectiveRange = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("assetId")
.Ascending("status")
.Ascending("effectiveFrom")
.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_asset_status_effectiveRange"
});
await collection.Indexes.CreateManyAsync(
new[]
{
tenantException,
tenantAsset,
tenantAdvisory,
tenantCve,
tenantExpires,
tenantEffectiveRange
},
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Migration to ensure all required Policy Engine collections exist.
/// Creates: policies, policy_revisions, policy_bundles, policy_runs, policy_audit, _policy_migrations
/// Note: effective_finding_* and effective_finding_history_* collections are created dynamically per-policy.
/// </summary>
internal sealed class EnsurePolicyCollectionsMigration : IPolicyEngineMongoMigration
{
private readonly ILogger<EnsurePolicyCollectionsMigration> _logger;
public EnsurePolicyCollectionsMigration(ILogger<EnsurePolicyCollectionsMigration> logger)
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
/// <inheritdoc />
public string Id => "20251128_policy_collections_v1";
/// <inheritdoc />
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var requiredCollections = new[]
{
context.Options.PoliciesCollection,
context.Options.PolicyRevisionsCollection,
context.Options.PolicyBundlesCollection,
context.Options.PolicyRunsCollection,
context.Options.AuditCollection,
context.Options.MigrationsCollection
};
var cursor = await context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var collection in requiredCollections)
{
if (existing.Contains(collection, StringComparer.Ordinal))
{
continue;
}
_logger.LogInformation("Creating Policy Engine Mongo collection '{CollectionName}'.", collection);
await context.Database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,312 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Migration to ensure all required indexes exist for Policy Engine collections.
/// Creates indexes for efficient tenant-scoped queries and TTL cleanup.
/// </summary>
internal sealed class EnsurePolicyIndexesMigration : IPolicyEngineMongoMigration
{
/// <inheritdoc />
public string Id => "20251128_policy_indexes_v1";
/// <inheritdoc />
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
await EnsurePoliciesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsurePolicyRevisionsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsurePolicyBundlesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsurePolicyRunsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureExplainsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policies collection.
/// </summary>
private static async Task EnsurePoliciesIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PoliciesCollection);
// Tenant lookup with optional tag filtering
var tenantTags = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("tags"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_tags"
});
// Tenant + updated for recent changes
var tenantUpdated = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("updatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_updatedAt_desc"
});
await collection.Indexes.CreateManyAsync(new[] { tenantTags, tenantUpdated }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_revisions collection.
/// </summary>
private static async Task EnsurePolicyRevisionsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyRevisionsCollection);
// Tenant + pack for finding revisions of a policy
var tenantPack = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("packId")
.Descending("version"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_pack_version_desc"
});
// Status lookup for finding active/draft revisions
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Bundle digest lookup for integrity verification
var bundleDigest = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("bundleDigest"),
new CreateIndexOptions<BsonDocument>
{
Name = "bundleDigest_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("bundleDigest", true)
});
await collection.Indexes.CreateManyAsync(new[] { tenantPack, tenantStatus, bundleDigest }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_bundles collection.
/// </summary>
private static async Task EnsurePolicyBundlesIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyBundlesCollection);
// Tenant + pack + version for finding specific bundles
var tenantPackVersion = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("packId")
.Ascending("version"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_pack_version",
Unique = true
});
await collection.Indexes.CreateManyAsync(new[] { tenantPackVersion }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_runs collection.
/// </summary>
private static async Task EnsurePolicyRunsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyRunsCollection);
// Tenant + policy + started for recent runs
var tenantPolicyStarted = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("policyId")
.Descending("startedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_policy_startedAt_desc"
});
// Status lookup for finding pending/running evaluations
var tenantStatus = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_status"
});
// Correlation ID lookup for tracing
var correlationId = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("correlationId"),
new CreateIndexOptions<BsonDocument>
{
Name = "correlationId_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("correlationId", true)
});
// Trace ID lookup for OpenTelemetry
var traceId = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("traceId"),
new CreateIndexOptions<BsonDocument>
{
Name = "traceId_lookup",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("traceId", true)
});
var models = new List<CreateIndexModel<BsonDocument>>
{
tenantPolicyStarted,
tenantStatus,
correlationId,
traceId
};
// TTL index for automatic cleanup of completed runs
if (context.Options.PolicyRunRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
models.Add(ttlModel);
}
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_audit collection.
/// </summary>
private static async Task EnsureAuditIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.AuditCollection);
// Tenant + occurred for chronological audit trail
var tenantOccurred = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("occurredAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_occurredAt_desc"
});
// Actor lookup for finding actions by user
var tenantActor = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("actorId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_actor"
});
// Resource lookup for finding actions on specific policy
var tenantResource = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("resourceType")
.Ascending("resourceId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_resource"
});
await collection.Indexes.CreateManyAsync(new[] { tenantOccurred, tenantActor, tenantResource }, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Creates indexes for the policy_explains collection.
/// </summary>
private static async Task EnsureExplainsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyExplainsCollection);
// Tenant + run for finding all explains in a run
var tenantRun = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("runId"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_runId"
});
// Tenant + policy + evaluated time for recent explains
var tenantPolicyEvaluated = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("policyId")
.Descending("evaluatedAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_policy_evaluatedAt_desc"
});
// Subject hash lookup for decision linkage
var subjectHash = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("subjectHash"),
new CreateIndexOptions<BsonDocument>
{
Name = "tenant_subjectHash"
});
// AOC chain lookup for attestation queries
var aocCompilation = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys
.Ascending("aocChain.compilationId"),
new CreateIndexOptions<BsonDocument>
{
Name = "aocChain_compilationId",
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("aocChain.compilationId", true)
});
var models = new List<CreateIndexModel<BsonDocument>>
{
tenantRun,
tenantPolicyEvaluated,
subjectHash,
aocCompilation
};
// TTL index for automatic cleanup
if (context.Options.ExplainTraceRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions<BsonDocument>
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
models.Add(ttlModel);
}
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Interface for Policy Engine MongoDB migrations.
/// Migrations are applied in lexical order by Id and tracked to ensure idempotency.
/// </summary>
internal interface IPolicyEngineMongoMigration
{
/// <summary>
/// Unique migration identifier.
/// Format: YYYYMMDD_description_vN (e.g., "20251128_policy_collections_v1")
/// </summary>
string Id { get; }
/// <summary>
/// Executes the migration against the Policy Engine database.
/// </summary>
/// <param name="context">MongoDB context with database access.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,30 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// MongoDB document for tracking applied migrations.
/// Collection: _policy_migrations
/// </summary>
[BsonIgnoreExtraElements]
internal sealed class PolicyEngineMigrationRecord
{
/// <summary>
/// MongoDB ObjectId.
/// </summary>
[BsonId]
public ObjectId Id { get; set; }
/// <summary>
/// Unique migration identifier (matches IPolicyEngineMongoMigration.Id).
/// </summary>
[BsonElement("migrationId")]
public string MigrationId { get; set; } = string.Empty;
/// <summary>
/// When the migration was applied.
/// </summary>
[BsonElement("appliedAt")]
public DateTimeOffset AppliedAt { get; set; }
}

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
/// <summary>
/// Executes Policy Engine MongoDB migrations in order.
/// Tracks applied migrations to ensure idempotency.
/// </summary>
internal sealed class PolicyEngineMigrationRunner
{
private readonly PolicyEngineMongoContext _context;
private readonly IReadOnlyList<IPolicyEngineMongoMigration> _migrations;
private readonly ILogger<PolicyEngineMigrationRunner> _logger;
public PolicyEngineMigrationRunner(
PolicyEngineMongoContext context,
IEnumerable<IPolicyEngineMongoMigration> migrations,
ILogger<PolicyEngineMigrationRunner> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
ArgumentNullException.ThrowIfNull(migrations);
_migrations = migrations.OrderBy(m => m.Id, StringComparer.Ordinal).ToArray();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs all pending migrations.
/// </summary>
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
if (_migrations.Count == 0)
{
return;
}
var collection = _context.Database.GetCollection<PolicyEngineMigrationRecord>(_context.Options.MigrationsCollection);
await EnsureMigrationIndexAsync(collection, cancellationToken).ConfigureAwait(false);
var applied = await collection
.Find(FilterDefinition<PolicyEngineMigrationRecord>.Empty)
.Project(record => record.MigrationId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var appliedSet = applied.ToHashSet(StringComparer.Ordinal);
foreach (var migration in _migrations)
{
if (appliedSet.Contains(migration.Id))
{
continue;
}
_logger.LogInformation("Applying Policy Engine Mongo migration {MigrationId}.", migration.Id);
await migration.ExecuteAsync(_context, cancellationToken).ConfigureAwait(false);
var record = new PolicyEngineMigrationRecord
{
Id = ObjectId.GenerateNewId(),
MigrationId = migration.Id,
AppliedAt = DateTimeOffset.UtcNow
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Completed Policy Engine Mongo migration {MigrationId}.", migration.Id);
}
}
private static async Task EnsureMigrationIndexAsync(
IMongoCollection<PolicyEngineMigrationRecord> collection,
CancellationToken cancellationToken)
{
var keys = Builders<PolicyEngineMigrationRecord>.IndexKeys.Ascending(record => record.MigrationId);
var model = new CreateIndexModel<PolicyEngineMigrationRecord>(keys, new CreateIndexOptions
{
Name = "migrationId_unique",
Unique = true
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,140 @@
namespace StellaOps.Policy.Engine.Storage.Mongo.Options;
/// <summary>
/// Configures MongoDB connectivity and collection names for Policy Engine storage.
/// </summary>
public sealed class PolicyEngineMongoOptions
{
/// <summary>
/// MongoDB connection string.
/// </summary>
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
/// <summary>
/// Database name for policy storage.
/// </summary>
public string Database { get; set; } = "stellaops_policy";
/// <summary>
/// Collection name for policy packs.
/// </summary>
public string PoliciesCollection { get; set; } = "policies";
/// <summary>
/// Collection name for policy revisions.
/// </summary>
public string PolicyRevisionsCollection { get; set; } = "policy_revisions";
/// <summary>
/// Collection name for policy bundles (compiled artifacts).
/// </summary>
public string PolicyBundlesCollection { get; set; } = "policy_bundles";
/// <summary>
/// Collection name for policy evaluation runs.
/// </summary>
public string PolicyRunsCollection { get; set; } = "policy_runs";
/// <summary>
/// Collection prefix for effective findings (per-policy tenant-scoped).
/// Final collection name: {prefix}_{policyId}
/// </summary>
public string EffectiveFindingsCollectionPrefix { get; set; } = "effective_finding";
/// <summary>
/// Collection prefix for effective findings history (append-only).
/// Final collection name: {prefix}_{policyId}
/// </summary>
public string EffectiveFindingsHistoryCollectionPrefix { get; set; } = "effective_finding_history";
/// <summary>
/// Collection name for policy audit log.
/// </summary>
public string AuditCollection { get; set; } = "policy_audit";
/// <summary>
/// Collection name for policy explain traces.
/// </summary>
public string PolicyExplainsCollection { get; set; } = "policy_explains";
/// <summary>
/// Collection name for policy exceptions.
/// </summary>
public string ExceptionsCollection { get; set; } = "exceptions";
/// <summary>
/// Collection name for exception reviews.
/// </summary>
public string ExceptionReviewsCollection { get; set; } = "exception_reviews";
/// <summary>
/// Collection name for exception bindings.
/// </summary>
public string ExceptionBindingsCollection { get; set; } = "exception_bindings";
/// <summary>
/// Collection name for tracking applied migrations.
/// </summary>
public string MigrationsCollection { get; set; } = "_policy_migrations";
/// <summary>
/// TTL for completed policy runs. Zero or negative disables TTL.
/// </summary>
public TimeSpan PolicyRunRetention { get; set; } = TimeSpan.FromDays(90);
/// <summary>
/// TTL for effective findings history entries. Zero or negative disables TTL.
/// </summary>
public TimeSpan EffectiveFindingsHistoryRetention { get; set; } = TimeSpan.FromDays(365);
/// <summary>
/// TTL for explain traces. Zero or negative disables TTL.
/// </summary>
public TimeSpan ExplainTraceRetention { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Use majority read concern for consistency.
/// </summary>
public bool UseMajorityReadConcern { get; set; } = true;
/// <summary>
/// Use majority write concern for durability.
/// </summary>
public bool UseMajorityWriteConcern { get; set; } = true;
/// <summary>
/// Command timeout in seconds.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets the effective findings collection name for a policy.
/// </summary>
public string GetEffectiveFindingsCollectionName(string policyId)
{
var safePolicyId = SanitizeCollectionName(policyId);
return $"{EffectiveFindingsCollectionPrefix}_{safePolicyId}";
}
/// <summary>
/// Gets the effective findings history collection name for a policy.
/// </summary>
public string GetEffectiveFindingsHistoryCollectionName(string policyId)
{
var safePolicyId = SanitizeCollectionName(policyId);
return $"{EffectiveFindingsHistoryCollectionPrefix}_{safePolicyId}";
}
private static string SanitizeCollectionName(string name)
{
// Replace invalid characters with underscores
return string.Create(name.Length, name, (span, source) =>
{
for (int i = 0; i < source.Length; i++)
{
var c = source[i];
span[i] = char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_';
}
}).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,254 @@
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
/// <summary>
/// Repository interface for policy exception operations.
/// </summary>
internal interface IExceptionRepository
{
// Exception operations
/// <summary>
/// Creates a new exception.
/// </summary>
Task<PolicyExceptionDocument> CreateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken);
/// <summary>
/// Gets an exception by ID.
/// </summary>
Task<PolicyExceptionDocument?> GetExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Updates an existing exception.
/// </summary>
Task<PolicyExceptionDocument?> UpdateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken);
/// <summary>
/// Lists exceptions with filtering and pagination.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
string tenantId,
ExceptionQueryOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Finds active exceptions that apply to a specific asset/advisory.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset evaluationTime,
CancellationToken cancellationToken);
/// <summary>
/// Updates exception status.
/// </summary>
Task<bool> UpdateExceptionStatusAsync(
string tenantId,
string exceptionId,
string newStatus,
DateTimeOffset timestamp,
CancellationToken cancellationToken);
/// <summary>
/// Revokes an exception.
/// </summary>
Task<bool> RevokeExceptionAsync(
string tenantId,
string exceptionId,
string revokedBy,
string? reason,
DateTimeOffset timestamp,
CancellationToken cancellationToken);
/// <summary>
/// Gets exceptions expiring within a time window.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken);
/// <summary>
/// Gets exceptions that should be auto-activated.
/// </summary>
Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(
string tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken);
// Review operations
/// <summary>
/// Creates a new review for an exception.
/// </summary>
Task<ExceptionReviewDocument> CreateReviewAsync(
ExceptionReviewDocument review,
CancellationToken cancellationToken);
/// <summary>
/// Gets a review by ID.
/// </summary>
Task<ExceptionReviewDocument?> GetReviewAsync(
string tenantId,
string reviewId,
CancellationToken cancellationToken);
/// <summary>
/// Adds a decision to a review.
/// </summary>
Task<ExceptionReviewDocument?> AddReviewDecisionAsync(
string tenantId,
string reviewId,
ReviewDecisionDocument decision,
CancellationToken cancellationToken);
/// <summary>
/// Completes a review with final status.
/// </summary>
Task<ExceptionReviewDocument?> CompleteReviewAsync(
string tenantId,
string reviewId,
string finalStatus,
DateTimeOffset completedAt,
CancellationToken cancellationToken);
/// <summary>
/// Gets reviews for an exception.
/// </summary>
Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Gets pending reviews for a reviewer.
/// </summary>
Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(
string tenantId,
string? reviewerId,
CancellationToken cancellationToken);
// Binding operations
/// <summary>
/// Creates or updates a binding.
/// </summary>
Task<ExceptionBindingDocument> UpsertBindingAsync(
ExceptionBindingDocument binding,
CancellationToken cancellationToken);
/// <summary>
/// Gets bindings for an exception.
/// </summary>
Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Gets active bindings for an asset.
/// </summary>
Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(
string tenantId,
string assetId,
DateTimeOffset asOf,
CancellationToken cancellationToken);
/// <summary>
/// Deletes bindings for an exception.
/// </summary>
Task<long> DeleteBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken);
/// <summary>
/// Updates binding status.
/// </summary>
Task<bool> UpdateBindingStatusAsync(
string tenantId,
string bindingId,
string newStatus,
CancellationToken cancellationToken);
/// <summary>
/// Gets expired bindings for cleanup.
/// </summary>
Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(
string tenantId,
DateTimeOffset asOf,
int limit,
CancellationToken cancellationToken);
// Statistics
/// <summary>
/// Gets exception counts by status.
/// </summary>
Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(
string tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Query options for listing exceptions.
/// </summary>
public sealed record ExceptionQueryOptions
{
/// <summary>
/// Filter by status.
/// </summary>
public ImmutableArray<string> Statuses { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Filter by exception type.
/// </summary>
public ImmutableArray<string> Types { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Filter by tag.
/// </summary>
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Filter by creator.
/// </summary>
public string? CreatedBy { get; init; }
/// <summary>
/// Include expired exceptions.
/// </summary>
public bool IncludeExpired { get; init; }
/// <summary>
/// Skip count for pagination.
/// </summary>
public int Skip { get; init; }
/// <summary>
/// Limit for pagination (default 100).
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Sort field.
/// </summary>
public string SortBy { get; init; } = "createdAt";
/// <summary>
/// Sort direction (asc or desc).
/// </summary>
public string SortDirection { get; init; } = "desc";
}

View File

@@ -0,0 +1,611 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
using StellaOps.Policy.Engine.Storage.Mongo.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of the exception repository.
/// </summary>
internal sealed class MongoExceptionRepository : IExceptionRepository
{
private readonly IMongoDatabase _database;
private readonly PolicyEngineMongoOptions _options;
private readonly ILogger<MongoExceptionRepository> _logger;
public MongoExceptionRepository(
IMongoClient mongoClient,
IOptions<PolicyEngineMongoOptions> options,
ILogger<MongoExceptionRepository> logger)
{
ArgumentNullException.ThrowIfNull(mongoClient);
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_database = mongoClient.GetDatabase(_options.Database);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private IMongoCollection<PolicyExceptionDocument> Exceptions
=> _database.GetCollection<PolicyExceptionDocument>(_options.ExceptionsCollection);
private IMongoCollection<ExceptionReviewDocument> Reviews
=> _database.GetCollection<ExceptionReviewDocument>(_options.ExceptionReviewsCollection);
private IMongoCollection<ExceptionBindingDocument> Bindings
=> _database.GetCollection<ExceptionBindingDocument>(_options.ExceptionBindingsCollection);
#region Exception Operations
public async Task<PolicyExceptionDocument> CreateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(exception);
exception.TenantId = exception.TenantId.ToLowerInvariant();
await Exceptions.InsertOneAsync(exception, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created exception {ExceptionId} for tenant {TenantId}",
exception.Id, exception.TenantId);
PolicyEngineTelemetry.RecordExceptionOperation(exception.TenantId, "create");
return exception;
}
public async Task<PolicyExceptionDocument?> GetExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
return await Exceptions.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<PolicyExceptionDocument?> UpdateExceptionAsync(
PolicyExceptionDocument exception,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(exception);
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, exception.TenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exception.Id));
var result = await Exceptions.ReplaceOneAsync(filter, exception, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result.ModifiedCount > 0)
{
_logger.LogInformation(
"Updated exception {ExceptionId} for tenant {TenantId}",
exception.Id, exception.TenantId);
PolicyEngineTelemetry.RecordExceptionOperation(exception.TenantId, "update");
return exception;
}
return null;
}
public async Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
string tenantId,
ExceptionQueryOptions options,
CancellationToken cancellationToken)
{
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
var filters = new List<FilterDefinition<PolicyExceptionDocument>>
{
filterBuilder.Eq(e => e.TenantId, tenantId.ToLowerInvariant())
};
if (options.Statuses.Length > 0)
{
filters.Add(filterBuilder.In(e => e.Status, options.Statuses));
}
if (options.Types.Length > 0)
{
filters.Add(filterBuilder.In(e => e.ExceptionType, options.Types));
}
if (options.Tags.Length > 0)
{
filters.Add(filterBuilder.AnyIn(e => e.Tags, options.Tags));
}
if (!string.IsNullOrEmpty(options.CreatedBy))
{
filters.Add(filterBuilder.Eq(e => e.CreatedBy, options.CreatedBy));
}
if (!options.IncludeExpired)
{
var now = DateTimeOffset.UtcNow;
filters.Add(filterBuilder.Or(
filterBuilder.Eq(e => e.ExpiresAt, null),
filterBuilder.Gt(e => e.ExpiresAt, now)));
}
var filter = filterBuilder.And(filters);
var sort = options.SortDirection.Equals("asc", StringComparison.OrdinalIgnoreCase)
? Builders<PolicyExceptionDocument>.Sort.Ascending(options.SortBy)
: Builders<PolicyExceptionDocument>.Sort.Descending(options.SortBy);
var results = await Exceptions
.Find(filter)
.Sort(sort)
.Skip(options.Skip)
.Limit(options.Limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset evaluationTime,
CancellationToken cancellationToken)
{
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
var filters = new List<FilterDefinition<PolicyExceptionDocument>>
{
filterBuilder.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
filterBuilder.Eq(e => e.Status, "active"),
filterBuilder.Or(
filterBuilder.Eq(e => e.EffectiveFrom, null),
filterBuilder.Lte(e => e.EffectiveFrom, evaluationTime)),
filterBuilder.Or(
filterBuilder.Eq(e => e.ExpiresAt, null),
filterBuilder.Gt(e => e.ExpiresAt, evaluationTime))
};
// Scope matching - must match at least one criterion
var scopeFilters = new List<FilterDefinition<PolicyExceptionDocument>>
{
filterBuilder.Eq("scope.applyToAll", true),
filterBuilder.AnyEq("scope.assetIds", assetId)
};
// Add PURL pattern matching (simplified - would need regex in production)
scopeFilters.Add(filterBuilder.Not(filterBuilder.Size("scope.purlPatterns", 0)));
if (!string.IsNullOrEmpty(advisoryId))
{
scopeFilters.Add(filterBuilder.AnyEq("scope.advisoryIds", advisoryId));
}
filters.Add(filterBuilder.Or(scopeFilters));
var filter = filterBuilder.And(filters);
var results = await Exceptions
.Find(filter)
.Sort(Builders<PolicyExceptionDocument>.Sort.Descending(e => e.Priority))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<bool> UpdateExceptionStatusAsync(
string tenantId,
string exceptionId,
string newStatus,
DateTimeOffset timestamp,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
var updateBuilder = Builders<PolicyExceptionDocument>.Update;
var updates = new List<UpdateDefinition<PolicyExceptionDocument>>
{
updateBuilder.Set(e => e.Status, newStatus),
updateBuilder.Set(e => e.UpdatedAt, timestamp)
};
if (newStatus == "active")
{
updates.Add(updateBuilder.Set(e => e.ActivatedAt, timestamp));
}
var update = updateBuilder.Combine(updates);
var result = await Exceptions.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result.ModifiedCount > 0)
{
_logger.LogInformation(
"Updated exception {ExceptionId} status to {Status} for tenant {TenantId}",
exceptionId, newStatus, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"status_{newStatus}");
}
return result.ModifiedCount > 0;
}
public async Task<bool> RevokeExceptionAsync(
string tenantId,
string exceptionId,
string revokedBy,
string? reason,
DateTimeOffset timestamp,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
var update = Builders<PolicyExceptionDocument>.Update
.Set(e => e.Status, "revoked")
.Set(e => e.RevokedAt, timestamp)
.Set(e => e.RevokedBy, revokedBy)
.Set(e => e.RevocationReason, reason)
.Set(e => e.UpdatedAt, timestamp);
var result = await Exceptions.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (result.ModifiedCount > 0)
{
_logger.LogInformation(
"Revoked exception {ExceptionId} by {RevokedBy} for tenant {TenantId}",
exceptionId, revokedBy, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, "revoke");
}
return result.ModifiedCount > 0;
}
public async Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Status, "active"),
Builders<PolicyExceptionDocument>.Filter.Gte(e => e.ExpiresAt, from),
Builders<PolicyExceptionDocument>.Filter.Lte(e => e.ExpiresAt, to));
var results = await Exceptions
.Find(filter)
.Sort(Builders<PolicyExceptionDocument>.Sort.Ascending(e => e.ExpiresAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(
string tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var filter = Builders<PolicyExceptionDocument>.Filter.And(
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Status, "approved"),
Builders<PolicyExceptionDocument>.Filter.Lte(e => e.EffectiveFrom, asOf));
var results = await Exceptions
.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
#endregion
#region Review Operations
public async Task<ExceptionReviewDocument> CreateReviewAsync(
ExceptionReviewDocument review,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(review);
review.TenantId = review.TenantId.ToLowerInvariant();
await Reviews.InsertOneAsync(review, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Created review {ReviewId} for exception {ExceptionId}, tenant {TenantId}",
review.Id, review.ExceptionId, review.TenantId);
PolicyEngineTelemetry.RecordExceptionOperation(review.TenantId, "review_create");
return review;
}
public async Task<ExceptionReviewDocument?> GetReviewAsync(
string tenantId,
string reviewId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId));
return await Reviews.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<ExceptionReviewDocument?> AddReviewDecisionAsync(
string tenantId,
string reviewId,
ReviewDecisionDocument decision,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Status, "pending"));
var update = Builders<ExceptionReviewDocument>.Update
.Push(r => r.Decisions, decision);
var options = new FindOneAndUpdateOptions<ExceptionReviewDocument>
{
ReturnDocument = ReturnDocument.After
};
var result = await Reviews.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
.ConfigureAwait(false);
if (result is not null)
{
_logger.LogInformation(
"Added decision from {ReviewerId} to review {ReviewId} for tenant {TenantId}",
decision.ReviewerId, reviewId, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"review_decision_{decision.Decision}");
}
return result;
}
public async Task<ExceptionReviewDocument?> CompleteReviewAsync(
string tenantId,
string reviewId,
string finalStatus,
DateTimeOffset completedAt,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId));
var update = Builders<ExceptionReviewDocument>.Update
.Set(r => r.Status, finalStatus)
.Set(r => r.CompletedAt, completedAt);
var options = new FindOneAndUpdateOptions<ExceptionReviewDocument>
{
ReturnDocument = ReturnDocument.After
};
var result = await Reviews.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
.ConfigureAwait(false);
if (result is not null)
{
_logger.LogInformation(
"Completed review {ReviewId} with status {Status} for tenant {TenantId}",
reviewId, finalStatus, tenantId);
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"review_complete_{finalStatus}");
}
return result;
}
public async Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionReviewDocument>.Filter.And(
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.ExceptionId, exceptionId));
var results = await Reviews
.Find(filter)
.Sort(Builders<ExceptionReviewDocument>.Sort.Descending(r => r.RequestedAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(
string tenantId,
string? reviewerId,
CancellationToken cancellationToken)
{
var filterBuilder = Builders<ExceptionReviewDocument>.Filter;
var filters = new List<FilterDefinition<ExceptionReviewDocument>>
{
filterBuilder.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
filterBuilder.Eq(r => r.Status, "pending")
};
if (!string.IsNullOrEmpty(reviewerId))
{
filters.Add(filterBuilder.AnyEq(r => r.DesignatedReviewers, reviewerId));
}
var filter = filterBuilder.And(filters);
var results = await Reviews
.Find(filter)
.Sort(Builders<ExceptionReviewDocument>.Sort.Ascending(r => r.Deadline))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
#endregion
#region Binding Operations
public async Task<ExceptionBindingDocument> UpsertBindingAsync(
ExceptionBindingDocument binding,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(binding);
binding.TenantId = binding.TenantId.ToLowerInvariant();
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, binding.TenantId),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Id, binding.Id));
var options = new ReplaceOptions { IsUpsert = true };
await Bindings.ReplaceOneAsync(filter, binding, options, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Upserted binding {BindingId} for tenant {TenantId}",
binding.Id, binding.TenantId);
return binding;
}
public async Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExceptionId, exceptionId));
var results = await Bindings
.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(
string tenantId,
string assetId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.AssetId, assetId),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Status, "active"),
Builders<ExceptionBindingDocument>.Filter.Lte(b => b.EffectiveFrom, asOf),
Builders<ExceptionBindingDocument>.Filter.Or(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExpiresAt, null),
Builders<ExceptionBindingDocument>.Filter.Gt(b => b.ExpiresAt, asOf)));
var results = await Bindings
.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<long> DeleteBindingsForExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExceptionId, exceptionId));
var result = await Bindings.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Deleted {Count} bindings for exception {ExceptionId} tenant {TenantId}",
result.DeletedCount, exceptionId, tenantId);
return result.DeletedCount;
}
public async Task<bool> UpdateBindingStatusAsync(
string tenantId,
string bindingId,
string newStatus,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Id, bindingId));
var update = Builders<ExceptionBindingDocument>.Update.Set(b => b.Status, newStatus);
var result = await Bindings.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return result.ModifiedCount > 0;
}
public async Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(
string tenantId,
DateTimeOffset asOf,
int limit,
CancellationToken cancellationToken)
{
var filter = Builders<ExceptionBindingDocument>.Filter.And(
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Status, "active"),
Builders<ExceptionBindingDocument>.Filter.Lt(b => b.ExpiresAt, asOf));
var results = await Bindings
.Find(filter)
.Limit(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToImmutableArray();
}
#endregion
#region Statistics
public async Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(
string tenantId,
CancellationToken cancellationToken)
{
var pipeline = new BsonDocument[]
{
new("$match", new BsonDocument("tenantId", tenantId.ToLowerInvariant())),
new("$group", new BsonDocument
{
{ "_id", "$status" },
{ "count", new BsonDocument("$sum", 1) }
})
};
var results = await Exceptions
.Aggregate<BsonDocument>(pipeline, cancellationToken: cancellationToken)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return results.ToDictionary(
r => r["_id"].AsString,
r => r["count"].AsInt32);
}
#endregion
}

View File

@@ -0,0 +1,496 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
// Alias to disambiguate from StellaOps.Policy.PolicyDocument (compiled policy IR)
using PolicyPackDocument = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyDocument;
using PolicyRevisionDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyRevisionDocument;
using PolicyBundleDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyBundleDocument;
using PolicyApprovalRec = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyApprovalRecord;
using PolicyAocMetadataDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyAocMetadataDocument;
using PolicyProvenanceDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyProvenanceDocument;
using PolicyAttestationRefDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyAttestationRefDocument;
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
/// <summary>
/// MongoDB implementation of policy pack repository with tenant scoping.
/// </summary>
internal sealed class MongoPolicyPackRepository : IPolicyPackRepository
{
private readonly PolicyEngineMongoContext _context;
private readonly ILogger<MongoPolicyPackRepository> _logger;
private readonly TimeProvider _timeProvider;
private readonly string _tenantId;
public MongoPolicyPackRepository(
PolicyEngineMongoContext context,
ILogger<MongoPolicyPackRepository> logger,
TimeProvider timeProvider,
string tenantId)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_tenantId = tenantId?.ToLowerInvariant() ?? throw new ArgumentNullException(nameof(tenantId));
}
private IMongoCollection<PolicyPackDocument> Policies =>
_context.Database.GetCollection<PolicyPackDocument>(_context.Options.PoliciesCollection);
private IMongoCollection<PolicyRevisionDoc> Revisions =>
_context.Database.GetCollection<PolicyRevisionDoc>(_context.Options.PolicyRevisionsCollection);
private IMongoCollection<PolicyBundleDoc> Bundles =>
_context.Database.GetCollection<PolicyBundleDoc>(_context.Options.PolicyBundlesCollection);
/// <inheritdoc />
public async Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(packId);
var now = _timeProvider.GetUtcNow();
var document = new PolicyPackDocument
{
Id = packId,
TenantId = _tenantId,
DisplayName = displayName,
LatestVersion = 0,
CreatedAt = now,
UpdatedAt = now
};
try
{
await Policies.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Created policy pack {PackId} for tenant {TenantId}", packId, _tenantId);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
_logger.LogDebug("Policy pack {PackId} already exists for tenant {TenantId}", packId, _tenantId);
var existing = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
throw new InvalidOperationException($"Policy pack {packId} exists but not for tenant {_tenantId}");
}
return ToDomain(existing);
}
return ToDomain(document);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken)
{
var documents = await Policies
.Find(p => p.TenantId == _tenantId)
.SortBy(p => p.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents.Select(ToDomain).ToList().AsReadOnly();
}
/// <inheritdoc />
public async Task<PolicyRevisionRecord> UpsertRevisionAsync(
string packId,
int version,
bool requiresTwoPersonApproval,
PolicyRevisionStatus initialStatus,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
// Ensure pack exists
var pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
pack = new PolicyPackDocument
{
Id = packId,
TenantId = _tenantId,
LatestVersion = 0,
CreatedAt = now,
UpdatedAt = now
};
try
{
await Policies.InsertOneAsync(pack, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstAsync(cancellationToken)
.ConfigureAwait(false);
}
}
// Determine version
var targetVersion = version > 0 ? version : pack.LatestVersion + 1;
var revisionId = PolicyRevisionDoc.CreateId(packId, targetVersion);
// Upsert revision
var filter = Builders<PolicyRevisionDoc>.Filter.Eq(r => r.Id, revisionId);
var update = Builders<PolicyRevisionDoc>.Update
.SetOnInsert(r => r.Id, revisionId)
.SetOnInsert(r => r.TenantId, _tenantId)
.SetOnInsert(r => r.PackId, packId)
.SetOnInsert(r => r.Version, targetVersion)
.SetOnInsert(r => r.RequiresTwoPersonApproval, requiresTwoPersonApproval)
.SetOnInsert(r => r.CreatedAt, now)
.Set(r => r.Status, initialStatus.ToString());
var options = new FindOneAndUpdateOptions<PolicyRevisionDoc>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
};
var revision = await Revisions.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
.ConfigureAwait(false);
// Update pack latest version
if (targetVersion > pack.LatestVersion)
{
await Policies.UpdateOneAsync(
p => p.Id == packId && p.TenantId == _tenantId,
Builders<PolicyPackDocument>.Update
.Set(p => p.LatestVersion, targetVersion)
.Set(p => p.UpdatedAt, now),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
_logger.LogDebug(
"Upserted revision {PackId}:{Version} for tenant {TenantId}",
packId, targetVersion, _tenantId);
return ToDomain(revision);
}
/// <inheritdoc />
public async Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken)
{
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
var revision = await Revisions
.Find(r => r.Id == revisionId && r.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (revision is null)
{
return null;
}
// Load bundle if referenced
PolicyBundleDoc? bundle = null;
if (!string.IsNullOrEmpty(revision.BundleId))
{
bundle = await Bundles
.Find(b => b.Id == revision.BundleId && b.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
return ToDomain(revision, bundle);
}
/// <inheritdoc />
public async Task<PolicyActivationResult> RecordActivationAsync(
string packId,
int version,
string actorId,
DateTimeOffset timestamp,
string? comment,
CancellationToken cancellationToken)
{
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
// Get current revision
var revision = await Revisions
.Find(r => r.Id == revisionId && r.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (revision is null)
{
var pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return pack is null
? new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null)
: new PolicyActivationResult(PolicyActivationResultStatus.RevisionNotFound, null);
}
if (revision.Status == PolicyRevisionStatus.Active.ToString())
{
return new PolicyActivationResult(PolicyActivationResultStatus.AlreadyActive, ToDomain(revision));
}
if (revision.Status != PolicyRevisionStatus.Approved.ToString())
{
return new PolicyActivationResult(PolicyActivationResultStatus.NotApproved, ToDomain(revision));
}
// Check for duplicate approval
if (revision.Approvals.Any(a => a.ActorId.Equals(actorId, StringComparison.OrdinalIgnoreCase)))
{
return new PolicyActivationResult(PolicyActivationResultStatus.DuplicateApproval, ToDomain(revision));
}
// Add approval
var approval = new PolicyApprovalRec
{
ActorId = actorId,
ApprovedAt = timestamp,
Comment = comment
};
var approvalUpdate = Builders<PolicyRevisionDoc>.Update.Push(r => r.Approvals, approval);
await Revisions.UpdateOneAsync(r => r.Id == revisionId, approvalUpdate, cancellationToken: cancellationToken)
.ConfigureAwait(false);
revision.Approvals.Add(approval);
// Check if we have enough approvals
var approvalCount = revision.Approvals.Count;
if (revision.RequiresTwoPersonApproval && approvalCount < 2)
{
return new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, ToDomain(revision));
}
// Activate
var activateUpdate = Builders<PolicyRevisionDoc>.Update
.Set(r => r.Status, PolicyRevisionStatus.Active.ToString())
.Set(r => r.ActivatedAt, timestamp);
await Revisions.UpdateOneAsync(r => r.Id == revisionId, activateUpdate, cancellationToken: cancellationToken)
.ConfigureAwait(false);
// Update pack active version
await Policies.UpdateOneAsync(
p => p.Id == packId && p.TenantId == _tenantId,
Builders<PolicyPackDocument>.Update
.Set(p => p.ActiveVersion, version)
.Set(p => p.UpdatedAt, timestamp),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
revision.Status = PolicyRevisionStatus.Active.ToString();
revision.ActivatedAt = timestamp;
_logger.LogInformation(
"Activated revision {PackId}:{Version} for tenant {TenantId} by {ActorId}",
packId, version, _tenantId, actorId);
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, ToDomain(revision));
}
/// <inheritdoc />
public async Task<PolicyBundleRecord> StoreBundleAsync(
string packId,
int version,
PolicyBundleRecord bundle,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bundle);
var now = _timeProvider.GetUtcNow();
// Ensure revision exists
await UpsertRevisionAsync(packId, version, requiresTwoPersonApproval: false, PolicyRevisionStatus.Draft, cancellationToken)
.ConfigureAwait(false);
// Create bundle document
var bundleDoc = new PolicyBundleDoc
{
Id = bundle.Digest,
TenantId = _tenantId,
PackId = packId,
Version = version,
Signature = bundle.Signature,
SizeBytes = bundle.Size,
Payload = bundle.Payload.ToArray(),
CreatedAt = bundle.CreatedAt,
AocMetadata = bundle.AocMetadata is not null ? ToDocument(bundle.AocMetadata) : null
};
// Upsert bundle
await Bundles.ReplaceOneAsync(
b => b.Id == bundle.Digest && b.TenantId == _tenantId,
bundleDoc,
new ReplaceOptions { IsUpsert = true },
cancellationToken)
.ConfigureAwait(false);
// Link revision to bundle
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
await Revisions.UpdateOneAsync(
r => r.Id == revisionId && r.TenantId == _tenantId,
Builders<PolicyRevisionDoc>.Update
.Set(r => r.BundleId, bundle.Digest)
.Set(r => r.BundleDigest, bundle.Digest),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Stored bundle {Digest} for {PackId}:{Version} tenant {TenantId}",
bundle.Digest, packId, version, _tenantId);
return bundle;
}
/// <inheritdoc />
public async Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken)
{
var bundle = await Bundles
.Find(b => b.PackId == packId && b.Version == version && b.TenantId == _tenantId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return bundle is null ? null : ToDomain(bundle);
}
#region Mapping
private static PolicyPackRecord ToDomain(PolicyPackDocument doc)
{
return new PolicyPackRecord(doc.Id, doc.DisplayName, doc.CreatedAt);
}
private static PolicyRevisionRecord ToDomain(PolicyRevisionDoc doc, PolicyBundleDoc? bundleDoc = null)
{
var status = Enum.TryParse<PolicyRevisionStatus>(doc.Status, ignoreCase: true, out var s)
? s
: PolicyRevisionStatus.Draft;
var revision = new PolicyRevisionRecord(doc.Version, doc.RequiresTwoPersonApproval, status, doc.CreatedAt);
if (doc.ActivatedAt.HasValue)
{
revision.SetStatus(PolicyRevisionStatus.Active, doc.ActivatedAt.Value);
}
foreach (var approval in doc.Approvals)
{
revision.AddApproval(new PolicyActivationApproval(approval.ActorId, approval.ApprovedAt, approval.Comment));
}
if (bundleDoc is not null)
{
revision.SetBundle(ToDomain(bundleDoc));
}
return revision;
}
private static PolicyBundleRecord ToDomain(PolicyBundleDoc doc)
{
PolicyAocMetadata? aocMetadata = null;
if (doc.AocMetadata is not null)
{
var aoc = doc.AocMetadata;
PolicyProvenance? provenance = null;
if (aoc.Provenance is not null)
{
var p = aoc.Provenance;
provenance = new PolicyProvenance(
p.SourceType,
p.SourceUrl,
p.Submitter,
p.CommitSha,
p.Branch,
p.IngestedAt);
}
PolicyAttestationRef? attestationRef = null;
if (aoc.AttestationRef is not null)
{
var a = aoc.AttestationRef;
attestationRef = new PolicyAttestationRef(
a.AttestationId,
a.EnvelopeDigest,
a.Uri,
a.SigningKeyId,
a.CreatedAt);
}
aocMetadata = new PolicyAocMetadata(
aoc.CompilationId,
aoc.CompilerVersion,
aoc.CompiledAt,
aoc.SourceDigest,
aoc.ArtifactDigest,
aoc.ComplexityScore,
aoc.RuleCount,
aoc.DurationMilliseconds,
provenance,
attestationRef);
}
return new PolicyBundleRecord(
doc.Id,
doc.Signature,
doc.SizeBytes,
doc.CreatedAt,
doc.Payload.ToImmutableArray(),
CompiledDocument: null, // Cannot serialize IR document to/from Mongo
aocMetadata);
}
private static PolicyAocMetadataDoc ToDocument(PolicyAocMetadata aoc)
{
return new PolicyAocMetadataDoc
{
CompilationId = aoc.CompilationId,
CompilerVersion = aoc.CompilerVersion,
CompiledAt = aoc.CompiledAt,
SourceDigest = aoc.SourceDigest,
ArtifactDigest = aoc.ArtifactDigest,
ComplexityScore = aoc.ComplexityScore,
RuleCount = aoc.RuleCount,
DurationMilliseconds = aoc.DurationMilliseconds,
Provenance = aoc.Provenance is not null ? ToDocument(aoc.Provenance) : null,
AttestationRef = aoc.AttestationRef is not null ? ToDocument(aoc.AttestationRef) : null
};
}
private static PolicyProvenanceDoc ToDocument(PolicyProvenance p)
{
return new PolicyProvenanceDoc
{
SourceType = p.SourceType,
SourceUrl = p.SourceUrl,
Submitter = p.Submitter,
CommitSha = p.CommitSha,
Branch = p.Branch,
IngestedAt = p.IngestedAt
};
}
private static PolicyAttestationRefDoc ToDocument(PolicyAttestationRef a)
{
return new PolicyAttestationRefDoc
{
AttestationId = a.AttestationId,
EnvelopeDigest = a.EnvelopeDigest,
Uri = a.Uri,
SigningKeyId = a.SigningKeyId,
CreatedAt = a.CreatedAt
};
}
#endregion
}

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
using StellaOps.Policy.Engine.Storage.Mongo.Migrations;
using StellaOps.Policy.Engine.Storage.Mongo.Options;
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
namespace StellaOps.Policy.Engine.Storage.Mongo;
/// <summary>
/// Extension methods for registering Policy Engine MongoDB storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Policy Engine MongoDB storage services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for PolicyEngineMongoOptions.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPolicyEngineMongoStorage(
this IServiceCollection services,
Action<PolicyEngineMongoOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
// Register options
if (configure is not null)
{
services.Configure(configure);
}
// Register context (singleton for connection pooling)
services.AddSingleton<PolicyEngineMongoContext>();
// Register migrations
services.AddSingleton<IPolicyEngineMongoMigration, EnsurePolicyCollectionsMigration>();
services.AddSingleton<IPolicyEngineMongoMigration, EnsurePolicyIndexesMigration>();
services.AddSingleton<IPolicyEngineMongoMigration, EnsureExceptionIndexesMigration>();
// Register migration runner
services.AddSingleton<PolicyEngineMigrationRunner>();
// Register initializer
services.AddSingleton<IPolicyEngineMongoInitializer, PolicyEngineMongoInitializer>();
// Register dynamic collection initializer for effective findings
services.AddSingleton<IEffectiveFindingCollectionInitializer, EffectiveFindingCollectionInitializer>();
// Register repositories
services.AddSingleton<IExceptionRepository, MongoExceptionRepository>();
return services;
}
/// <summary>
/// Adds Policy Engine MongoDB storage services with configuration binding from a configuration section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Configuration section containing PolicyEngineMongoOptions.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPolicyEngineMongoStorage(
this IServiceCollection services,
Microsoft.Extensions.Configuration.IConfigurationSection configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<PolicyEngineMongoOptions>(configuration);
return services.AddPolicyEngineMongoStorage(configure: null);
}
}

View File

@@ -291,6 +291,90 @@ public static class PolicyEngineTelemetry
/// </summary>
public static Counter<long> ProfileEventsPublished => ProfileEventsPublishedCounter;
// Counter: policy_events_processed_total
private static readonly Counter<long> PolicyEventsProcessedCounter =
Meter.CreateCounter<long>(
"policy_events_processed_total",
unit: "events",
description: "Total policy change events processed.");
/// <summary>
/// Counter for policy change events processed.
/// </summary>
public static Counter<long> PolicyEventsProcessed => PolicyEventsProcessedCounter;
// Counter: policy_effective_events_published_total
private static readonly Counter<long> PolicyEffectiveEventsPublishedCounter =
Meter.CreateCounter<long>(
"policy_effective_events_published_total",
unit: "events",
description: "Total policy.effective.* events published.");
/// <summary>
/// Counter for policy effective events published.
/// </summary>
public static Counter<long> PolicyEffectiveEventsPublished => PolicyEffectiveEventsPublishedCounter;
// Counter: policy_reevaluation_jobs_scheduled_total
private static readonly Counter<long> ReEvaluationJobsScheduledCounter =
Meter.CreateCounter<long>(
"policy_reevaluation_jobs_scheduled_total",
unit: "jobs",
description: "Total re-evaluation jobs scheduled.");
/// <summary>
/// Counter for re-evaluation jobs scheduled.
/// </summary>
public static Counter<long> ReEvaluationJobsScheduled => ReEvaluationJobsScheduledCounter;
// Counter: policy_explain_traces_stored_total
private static readonly Counter<long> ExplainTracesStoredCounter =
Meter.CreateCounter<long>(
"policy_explain_traces_stored_total",
unit: "traces",
description: "Total explain traces stored for decision audit.");
/// <summary>
/// Counter for explain traces stored.
/// </summary>
public static Counter<long> ExplainTracesStored => ExplainTracesStoredCounter;
// Counter: policy_effective_decision_map_operations_total
private static readonly Counter<long> EffectiveDecisionMapOperationsCounter =
Meter.CreateCounter<long>(
"policy_effective_decision_map_operations_total",
unit: "operations",
description: "Total effective decision map operations (set, get, invalidate).");
/// <summary>
/// Counter for effective decision map operations.
/// </summary>
public static Counter<long> EffectiveDecisionMapOperations => EffectiveDecisionMapOperationsCounter;
// Counter: policy_exception_operations_total{tenant,operation}
private static readonly Counter<long> ExceptionOperationsCounter =
Meter.CreateCounter<long>(
"policy_exception_operations_total",
unit: "operations",
description: "Total policy exception operations (create, update, revoke, review_*).");
/// <summary>
/// Counter for policy exception operations.
/// </summary>
public static Counter<long> ExceptionOperations => ExceptionOperationsCounter;
// Counter: policy_exception_cache_operations_total{tenant,operation}
private static readonly Counter<long> ExceptionCacheOperationsCounter =
Meter.CreateCounter<long>(
"policy_exception_cache_operations_total",
unit: "operations",
description: "Total exception cache operations (hit, miss, set, warm, invalidate).");
/// <summary>
/// Counter for exception cache operations.
/// </summary>
public static Counter<long> ExceptionCacheOperations => ExceptionCacheOperationsCounter;
#endregion
#region Reachability Metrics
@@ -506,6 +590,38 @@ public static class PolicyEngineTelemetry
PolicySimulationCounter.Add(1, tags);
}
/// <summary>
/// Records a policy exception operation.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="operation">Operation type (create, update, revoke, review_create, review_decision_*, etc.).</param>
public static void RecordExceptionOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionOperationsCounter.Add(1, tags);
}
/// <summary>
/// Records an exception cache operation.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="operation">Operation type (hit, miss, set, warm, invalidate_*, event_*).</param>
public static void RecordExceptionCacheOperation(string tenant, string operation)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
{ "operation", NormalizeTag(operation) },
};
ExceptionCacheOperationsCounter.Add(1, tags);
}
#region Golden Signals - Recording Methods
/// <summary>

View File

@@ -127,7 +127,7 @@ public sealed class PolicyEvaluationPredicate
/// Environment information.
/// </summary>
[JsonPropertyName("environment")]
public required PolicyEvaluationEnvironment Environment { get; init; }
public required AttestationEnvironment Environment { get; init; }
}
/// <summary>
@@ -167,9 +167,9 @@ public sealed class PolicyEvaluationMetrics
}
/// <summary>
/// Environment information for the evaluation.
/// Environment information for the attestation.
/// </summary>
public sealed class PolicyEvaluationEnvironment
public sealed class AttestationEnvironment
{
[JsonPropertyName("serviceVersion")]
public required string ServiceVersion { get; init; }
@@ -243,7 +243,7 @@ public sealed class PolicyEvaluationAttestationService
VexOverridesApplied = vexOverridesApplied,
DurationSeconds = durationSeconds,
},
Environment = new PolicyEvaluationEnvironment
Environment = new AttestationEnvironment
{
ServiceVersion = serviceVersion,
HostId = Environment.MachineName,
@@ -338,7 +338,7 @@ public sealed class DsseEnvelopeRequest
[JsonSerializable(typeof(InTotoSubject))]
[JsonSerializable(typeof(EvidenceBundleRef))]
[JsonSerializable(typeof(PolicyEvaluationMetrics))]
[JsonSerializable(typeof(PolicyEvaluationEnvironment))]
[JsonSerializable(typeof(AttestationEnvironment))]
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]

View File

@@ -0,0 +1,371 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.WhatIfSimulation;
/// <summary>
/// Request for what-if simulation supporting hypothetical SBOM diffs and draft policies.
/// </summary>
public sealed record WhatIfSimulationRequest
{
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Base snapshot ID to apply diffs to.
/// </summary>
[JsonPropertyName("base_snapshot_id")]
public required string BaseSnapshotId { get; init; }
/// <summary>
/// Active policy pack ID to use as baseline.
/// If DraftPolicy is provided, this will be compared against.
/// </summary>
[JsonPropertyName("baseline_pack_id")]
public string? BaselinePackId { get; init; }
/// <summary>
/// Baseline policy version. If null, uses active version.
/// </summary>
[JsonPropertyName("baseline_pack_version")]
public int? BaselinePackVersion { get; init; }
/// <summary>
/// Draft policy to simulate (not yet activated).
/// If null, uses baseline policy.
/// </summary>
[JsonPropertyName("draft_policy")]
public WhatIfDraftPolicy? DraftPolicy { get; init; }
/// <summary>
/// SBOM diffs to apply hypothetically.
/// </summary>
[JsonPropertyName("sbom_diffs")]
public ImmutableArray<WhatIfSbomDiff> SbomDiffs { get; init; } = ImmutableArray<WhatIfSbomDiff>.Empty;
/// <summary>
/// Specific component PURLs to evaluate. If empty, evaluates affected by diffs.
/// </summary>
[JsonPropertyName("target_purls")]
public ImmutableArray<string> TargetPurls { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Maximum number of components to evaluate.
/// </summary>
[JsonPropertyName("limit")]
public int Limit { get; init; } = 1000;
/// <summary>
/// Whether to include detailed explanations for each decision.
/// </summary>
[JsonPropertyName("include_explanations")]
public bool IncludeExplanations { get; init; } = false;
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Draft policy definition for simulation.
/// </summary>
public sealed record WhatIfDraftPolicy
{
/// <summary>
/// Draft policy pack ID.
/// </summary>
[JsonPropertyName("pack_id")]
public required string PackId { get; init; }
/// <summary>
/// Draft policy version.
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; }
/// <summary>
/// Raw YAML policy definition to compile and evaluate.
/// If provided, this is compiled on-the-fly.
/// </summary>
[JsonPropertyName("policy_yaml")]
public string? PolicyYaml { get; init; }
/// <summary>
/// Pre-compiled bundle digest if available.
/// </summary>
[JsonPropertyName("bundle_digest")]
public string? BundleDigest { get; init; }
}
/// <summary>
/// Hypothetical SBOM modification for what-if simulation.
/// </summary>
public sealed record WhatIfSbomDiff
{
/// <summary>
/// Type of modification: add, remove, upgrade, downgrade.
/// </summary>
[JsonPropertyName("operation")]
public required string Operation { get; init; }
/// <summary>
/// Component PURL being modified.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// New version for upgrade/downgrade operations.
/// </summary>
[JsonPropertyName("new_version")]
public string? NewVersion { get; init; }
/// <summary>
/// Original version (for reference in upgrades/downgrades).
/// </summary>
[JsonPropertyName("original_version")]
public string? OriginalVersion { get; init; }
/// <summary>
/// Hypothetical advisory IDs affecting this component.
/// </summary>
[JsonPropertyName("advisory_ids")]
public ImmutableArray<string> AdvisoryIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Hypothetical VEX status for this component.
/// </summary>
[JsonPropertyName("vex_status")]
public string? VexStatus { get; init; }
/// <summary>
/// Hypothetical reachability state.
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
/// <summary>
/// Response from what-if simulation.
/// </summary>
public sealed record WhatIfSimulationResponse
{
/// <summary>
/// Simulation identifier.
/// </summary>
[JsonPropertyName("simulation_id")]
public required string SimulationId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Base snapshot ID used.
/// </summary>
[JsonPropertyName("base_snapshot_id")]
public required string BaseSnapshotId { get; init; }
/// <summary>
/// Baseline policy used for comparison.
/// </summary>
[JsonPropertyName("baseline_policy")]
public required WhatIfPolicyRef BaselinePolicy { get; init; }
/// <summary>
/// Simulated policy (draft or modified).
/// </summary>
[JsonPropertyName("simulated_policy")]
public WhatIfPolicyRef? SimulatedPolicy { get; init; }
/// <summary>
/// Decision changes between baseline and simulation.
/// </summary>
[JsonPropertyName("decision_changes")]
public required ImmutableArray<WhatIfDecisionChange> DecisionChanges { get; init; }
/// <summary>
/// Summary of changes.
/// </summary>
[JsonPropertyName("summary")]
public required WhatIfSummary Summary { get; init; }
/// <summary>
/// When the simulation was executed.
/// </summary>
[JsonPropertyName("executed_at")]
public required DateTimeOffset ExecutedAt { get; init; }
/// <summary>
/// Execution duration in milliseconds.
/// </summary>
[JsonPropertyName("duration_ms")]
public long DurationMs { get; init; }
/// <summary>
/// Correlation ID.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Policy reference in simulation.
/// </summary>
public sealed record WhatIfPolicyRef(
[property: JsonPropertyName("pack_id")] string PackId,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("bundle_digest")] string? BundleDigest,
[property: JsonPropertyName("is_draft")] bool IsDraft);
/// <summary>
/// A decision change detected in what-if simulation.
/// </summary>
public sealed record WhatIfDecisionChange
{
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Advisory ID if applicable.
/// </summary>
[JsonPropertyName("advisory_id")]
public string? AdvisoryId { get; init; }
/// <summary>
/// Type of change: new, removed, status_changed, severity_changed.
/// </summary>
[JsonPropertyName("change_type")]
public required string ChangeType { get; init; }
/// <summary>
/// Baseline decision.
/// </summary>
[JsonPropertyName("baseline")]
public WhatIfDecision? Baseline { get; init; }
/// <summary>
/// Simulated decision.
/// </summary>
[JsonPropertyName("simulated")]
public WhatIfDecision? Simulated { get; init; }
/// <summary>
/// SBOM diff that caused this change, if any.
/// </summary>
[JsonPropertyName("caused_by_diff")]
public WhatIfSbomDiff? CausedByDiff { get; init; }
/// <summary>
/// Explanation for the change.
/// </summary>
[JsonPropertyName("explanation")]
public WhatIfExplanation? Explanation { get; init; }
}
/// <summary>
/// A decision in what-if simulation.
/// </summary>
public sealed record WhatIfDecision(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("severity")] string? Severity,
[property: JsonPropertyName("rule_name")] string? RuleName,
[property: JsonPropertyName("priority")] int? Priority,
[property: JsonPropertyName("exception_applied")] bool ExceptionApplied);
/// <summary>
/// Explanation for a what-if decision.
/// </summary>
public sealed record WhatIfExplanation
{
/// <summary>
/// Rules that matched.
/// </summary>
[JsonPropertyName("matched_rules")]
public ImmutableArray<string> MatchedRules { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Key factors in the decision.
/// </summary>
[JsonPropertyName("factors")]
public ImmutableArray<string> Factors { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// VEX evidence considered.
/// </summary>
[JsonPropertyName("vex_evidence")]
public string? VexEvidence { get; init; }
/// <summary>
/// Reachability state.
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
/// <summary>
/// Summary of what-if simulation results.
/// </summary>
public sealed record WhatIfSummary
{
/// <summary>
/// Total components evaluated.
/// </summary>
[JsonPropertyName("total_evaluated")]
public int TotalEvaluated { get; init; }
/// <summary>
/// Components with changed decisions.
/// </summary>
[JsonPropertyName("total_changed")]
public int TotalChanged { get; init; }
/// <summary>
/// Components newly affected.
/// </summary>
[JsonPropertyName("newly_affected")]
public int NewlyAffected { get; init; }
/// <summary>
/// Components no longer affected.
/// </summary>
[JsonPropertyName("no_longer_affected")]
public int NoLongerAffected { get; init; }
/// <summary>
/// Status changes by type.
/// </summary>
[JsonPropertyName("status_changes")]
public required ImmutableDictionary<string, int> StatusChanges { get; init; }
/// <summary>
/// Severity changes by type (e.g., "low_to_high").
/// </summary>
[JsonPropertyName("severity_changes")]
public required ImmutableDictionary<string, int> SeverityChanges { get; init; }
/// <summary>
/// Impact assessment.
/// </summary>
[JsonPropertyName("impact")]
public required WhatIfImpact Impact { get; init; }
}
/// <summary>
/// Impact assessment from what-if simulation.
/// </summary>
public sealed record WhatIfImpact(
[property: JsonPropertyName("risk_delta")] string RiskDelta, // increased, decreased, unchanged
[property: JsonPropertyName("blocked_count_delta")] int BlockedCountDelta,
[property: JsonPropertyName("warning_count_delta")] int WarningCountDelta,
[property: JsonPropertyName("recommendation")] string? Recommendation);

View File

@@ -0,0 +1,548 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.WhatIfSimulation;
/// <summary>
/// Service for Graph What-if API simulations.
/// Supports hypothetical SBOM diffs and draft policies without persisting results.
/// </summary>
internal sealed class WhatIfSimulationService
{
private readonly IEffectiveDecisionMap _decisionMap;
private readonly IPolicyPackRepository _policyRepository;
private readonly PolicyCompilationService _compilationService;
private readonly ILogger<WhatIfSimulationService> _logger;
private readonly TimeProvider _timeProvider;
public WhatIfSimulationService(
IEffectiveDecisionMap decisionMap,
IPolicyPackRepository policyRepository,
PolicyCompilationService compilationService,
ILogger<WhatIfSimulationService> logger,
TimeProvider timeProvider)
{
_decisionMap = decisionMap ?? throw new ArgumentNullException(nameof(decisionMap));
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
_compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Executes a what-if simulation without persisting results.
/// </summary>
public async Task<WhatIfSimulationResponse> SimulateAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"policy.whatif.simulate", ActivityKind.Internal);
activity?.SetTag("tenant_id", request.TenantId);
activity?.SetTag("base_snapshot_id", request.BaseSnapshotId);
activity?.SetTag("has_draft_policy", request.DraftPolicy is not null);
activity?.SetTag("sbom_diff_count", request.SbomDiffs.Length);
var sw = Stopwatch.StartNew();
var simulationId = GenerateSimulationId(request);
var executedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Starting what-if simulation {SimulationId} for tenant {TenantId}, snapshot {SnapshotId}",
simulationId, request.TenantId, request.BaseSnapshotId);
try
{
// Get baseline policy info
var baselinePolicy = await GetBaselinePolicyAsync(request, cancellationToken).ConfigureAwait(false);
// Get simulated policy info (draft or same as baseline)
var simulatedPolicy = await GetSimulatedPolicyAsync(request, cancellationToken).ConfigureAwait(false);
// Determine which components to evaluate
var targetPurls = await DetermineTargetPurlsAsync(request, cancellationToken).ConfigureAwait(false);
// Get baseline decisions from effective decision map
var baselineDecisions = await GetBaselineDecisionsAsync(
request.TenantId, request.BaseSnapshotId, targetPurls, cancellationToken).ConfigureAwait(false);
// Simulate decisions with hypothetical changes
var simulatedDecisions = await SimulateDecisionsAsync(
request, targetPurls, simulatedPolicy, cancellationToken).ConfigureAwait(false);
// Compute changes between baseline and simulated
var changes = ComputeChanges(
targetPurls, baselineDecisions, simulatedDecisions, request.SbomDiffs, request.IncludeExplanations);
// Compute summary
var summary = ComputeSummary(changes, baselineDecisions, simulatedDecisions);
sw.Stop();
_logger.LogInformation(
"Completed what-if simulation {SimulationId}: {Evaluated} evaluated, {Changed} changed in {Duration}ms",
simulationId, summary.TotalEvaluated, summary.TotalChanged, sw.ElapsedMilliseconds);
PolicyEngineTelemetry.RecordSimulation(request.TenantId, "success");
return new WhatIfSimulationResponse
{
SimulationId = simulationId,
TenantId = request.TenantId,
BaseSnapshotId = request.BaseSnapshotId,
BaselinePolicy = baselinePolicy,
SimulatedPolicy = simulatedPolicy,
DecisionChanges = changes,
Summary = summary,
ExecutedAt = executedAt,
DurationMs = sw.ElapsedMilliseconds,
CorrelationId = request.CorrelationId,
};
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "What-if simulation {SimulationId} failed", simulationId);
PolicyEngineTelemetry.RecordSimulation(request.TenantId, "failure");
PolicyEngineTelemetry.RecordError("whatif_simulation", request.TenantId);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task<WhatIfPolicyRef> GetBaselinePolicyAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.BaselinePackId is not null)
{
var version = request.BaselinePackVersion ?? 1;
// If no version specified, try to get the latest revision to find the active version
if (request.BaselinePackVersion is null)
{
var revision = await _policyRepository.GetRevisionAsync(request.BaselinePackId, 1, cancellationToken)
.ConfigureAwait(false);
if (revision?.Status == PolicyRevisionStatus.Active)
{
version = revision.Version;
}
}
var bundle = await _policyRepository.GetBundleAsync(request.BaselinePackId, version, cancellationToken)
.ConfigureAwait(false);
return new WhatIfPolicyRef(
request.BaselinePackId,
version,
bundle?.Digest,
IsDraft: false);
}
// Return a placeholder for "current effective policy"
return new WhatIfPolicyRef("default", 1, null, IsDraft: false);
}
private async Task<WhatIfPolicyRef?> GetSimulatedPolicyAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.DraftPolicy is null)
{
return null; // No draft - comparison is baseline vs hypothetical SBOM changes
}
string? bundleDigest = request.DraftPolicy.BundleDigest;
// If we have YAML, we could compile it on-the-fly (not persisting)
// For now, we just reference the draft
if (request.DraftPolicy.PolicyYaml is not null && bundleDigest is null)
{
// Compute a digest from the YAML for reference
bundleDigest = ComputeYamlDigest(request.DraftPolicy.PolicyYaml);
}
return new WhatIfPolicyRef(
request.DraftPolicy.PackId,
request.DraftPolicy.Version,
bundleDigest,
IsDraft: true);
}
private async Task<ImmutableArray<string>> DetermineTargetPurlsAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.TargetPurls.Length > 0)
{
return request.TargetPurls.Take(request.Limit).ToImmutableArray();
}
// Get PURLs from SBOM diffs
var diffPurls = request.SbomDiffs.Select(d => d.Purl).Distinct().ToList();
if (diffPurls.Count > 0)
{
return diffPurls.Take(request.Limit).ToImmutableArray();
}
// Get from effective decision map
var allDecisions = await _decisionMap.GetAllForSnapshotAsync(
request.TenantId,
request.BaseSnapshotId,
new EffectiveDecisionFilter { Limit = request.Limit },
cancellationToken).ConfigureAwait(false);
return allDecisions.Select(d => d.AssetId).ToImmutableArray();
}
private async Task<Dictionary<string, WhatIfDecision>> GetBaselineDecisionsAsync(
string tenantId,
string snapshotId,
ImmutableArray<string> purls,
CancellationToken cancellationToken)
{
var result = await _decisionMap.GetBatchAsync(tenantId, snapshotId, purls.ToList(), cancellationToken)
.ConfigureAwait(false);
var decisions = new Dictionary<string, WhatIfDecision>(StringComparer.OrdinalIgnoreCase);
foreach (var (purl, entry) in result.Entries)
{
decisions[purl] = new WhatIfDecision(
entry.Status,
entry.Severity,
entry.RuleName,
entry.Priority,
entry.ExceptionId is not null);
}
return decisions;
}
private Task<Dictionary<string, WhatIfDecision>> SimulateDecisionsAsync(
WhatIfSimulationRequest request,
ImmutableArray<string> targetPurls,
WhatIfPolicyRef? simulatedPolicy,
CancellationToken cancellationToken)
{
// In a full implementation, this would:
// 1. Apply SBOM diffs to compute hypothetical component states
// 2. If draft policy, compile and evaluate against the draft
// 3. Otherwise, re-evaluate with hypothetical context changes
//
// For now, we compute simulated decisions based on the diffs
var decisions = new Dictionary<string, WhatIfDecision>(StringComparer.OrdinalIgnoreCase);
var diffsByPurl = request.SbomDiffs.ToDictionary(d => d.Purl, StringComparer.OrdinalIgnoreCase);
foreach (var purl in targetPurls)
{
cancellationToken.ThrowIfCancellationRequested();
if (diffsByPurl.TryGetValue(purl, out var diff))
{
var decision = SimulateDecisionForDiff(diff, simulatedPolicy);
decisions[purl] = decision;
}
else
{
// No diff for this PURL - simulate based on policy change if any
decisions[purl] = SimulateDecisionWithoutDiff(purl, simulatedPolicy);
}
}
return Task.FromResult(decisions);
}
private static WhatIfDecision SimulateDecisionForDiff(WhatIfSbomDiff diff, WhatIfPolicyRef? policy)
{
// Simulate based on diff operation and properties
return diff.Operation.ToLowerInvariant() switch
{
"remove" => new WhatIfDecision("allow", null, null, null, false),
"add" => SimulateNewComponentDecision(diff),
"upgrade" => SimulateUpgradeDecision(diff),
"downgrade" => SimulateDowngradeDecision(diff),
_ => new WhatIfDecision("allow", null, null, null, false),
};
}
private static WhatIfDecision SimulateNewComponentDecision(WhatIfSbomDiff diff)
{
// New components are evaluated based on advisory presence
if (diff.AdvisoryIds.Length > 0)
{
var severity = DetermineSeverityFromAdvisories(diff.AdvisoryIds);
var status = severity switch
{
"critical" or "high" => "deny",
"medium" => "warn",
_ => "allow"
};
// VEX can override
if (diff.VexStatus?.Equals("not_affected", StringComparison.OrdinalIgnoreCase) == true)
{
status = "allow";
}
// Reachability can downgrade
if (diff.Reachability?.Equals("unreachable", StringComparison.OrdinalIgnoreCase) == true &&
status == "deny")
{
status = "warn";
}
return new WhatIfDecision(status, severity, "simulated_rule", 100, false);
}
return new WhatIfDecision("allow", null, null, null, false);
}
private static WhatIfDecision SimulateUpgradeDecision(WhatIfSbomDiff diff)
{
// Upgrades typically fix vulnerabilities
if (diff.AdvisoryIds.Length > 0)
{
// Some advisories remain
return new WhatIfDecision("warn", "low", "simulated_upgrade_rule", 50, false);
}
// Upgrade fixed all issues
return new WhatIfDecision("allow", null, "simulated_upgrade_rule", 50, false);
}
private static WhatIfDecision SimulateDowngradeDecision(WhatIfSbomDiff diff)
{
// Downgrades may introduce vulnerabilities
if (diff.AdvisoryIds.Length > 0)
{
var severity = DetermineSeverityFromAdvisories(diff.AdvisoryIds);
return new WhatIfDecision("deny", severity, "simulated_downgrade_rule", 150, false);
}
return new WhatIfDecision("warn", "low", "simulated_downgrade_rule", 150, false);
}
private static WhatIfDecision SimulateDecisionWithoutDiff(string purl, WhatIfPolicyRef? policy)
{
// If there's a draft policy, simulate potential changes from policy modification
if (policy?.IsDraft == true)
{
// Draft policies might change thresholds - simulate a potential change
return new WhatIfDecision("warn", "medium", "draft_policy_rule", 100, false);
}
// No change - return unchanged placeholder
return new WhatIfDecision("allow", null, null, null, false);
}
private static string DetermineSeverityFromAdvisories(ImmutableArray<string> advisoryIds)
{
// In reality, would look up actual severity from advisories
// For simulation, use a heuristic based on advisory count
if (advisoryIds.Length >= 5) return "critical";
if (advisoryIds.Length >= 3) return "high";
if (advisoryIds.Length >= 1) return "medium";
return "low";
}
private static ImmutableArray<WhatIfDecisionChange> ComputeChanges(
ImmutableArray<string> targetPurls,
Dictionary<string, WhatIfDecision> baseline,
Dictionary<string, WhatIfDecision> simulated,
ImmutableArray<WhatIfSbomDiff> diffs,
bool includeExplanations)
{
var changes = new List<WhatIfDecisionChange>();
var diffsByPurl = diffs.ToDictionary(d => d.Purl, StringComparer.OrdinalIgnoreCase);
foreach (var purl in targetPurls)
{
var hasBaseline = baseline.TryGetValue(purl, out var baselineDecision);
var hasSimulated = simulated.TryGetValue(purl, out var simulatedDecision);
diffsByPurl.TryGetValue(purl, out var diff);
string? changeType = null;
if (!hasBaseline && hasSimulated)
{
changeType = "new";
}
else if (hasBaseline && !hasSimulated)
{
changeType = "removed";
}
else if (hasBaseline && hasSimulated)
{
if (baselineDecision!.Status != simulatedDecision!.Status)
{
changeType = "status_changed";
}
else if (baselineDecision.Severity != simulatedDecision.Severity)
{
changeType = "severity_changed";
}
}
if (changeType is not null)
{
var explanation = includeExplanations
? BuildExplanation(diff, baselineDecision, simulatedDecision)
: null;
changes.Add(new WhatIfDecisionChange
{
Purl = purl,
AdvisoryId = diff?.AdvisoryIds.FirstOrDefault(),
ChangeType = changeType,
Baseline = baselineDecision,
Simulated = simulatedDecision,
CausedByDiff = diff,
Explanation = explanation,
});
}
}
return changes.ToImmutableArray();
}
private static WhatIfExplanation BuildExplanation(
WhatIfSbomDiff? diff,
WhatIfDecision? baseline,
WhatIfDecision? simulated)
{
var factors = new List<string>();
var rules = new List<string>();
if (diff is not null)
{
factors.Add($"SBOM {diff.Operation}: {diff.Purl}");
if (diff.NewVersion is not null)
{
factors.Add($"Version change: {diff.OriginalVersion ?? "unknown"} -> {diff.NewVersion}");
}
if (diff.AdvisoryIds.Length > 0)
{
factors.Add($"Advisories: {string.Join(", ", diff.AdvisoryIds.Take(3))}");
}
}
if (baseline?.RuleName is not null)
{
rules.Add($"baseline:{baseline.RuleName}");
}
if (simulated?.RuleName is not null)
{
rules.Add($"simulated:{simulated.RuleName}");
}
return new WhatIfExplanation
{
MatchedRules = rules.ToImmutableArray(),
Factors = factors.ToImmutableArray(),
VexEvidence = diff?.VexStatus,
Reachability = diff?.Reachability,
};
}
private static WhatIfSummary ComputeSummary(
ImmutableArray<WhatIfDecisionChange> changes,
Dictionary<string, WhatIfDecision> baseline,
Dictionary<string, WhatIfDecision> simulated)
{
var statusChanges = new Dictionary<string, int>();
var severityChanges = new Dictionary<string, int>();
var newlyAffected = 0;
var noLongerAffected = 0;
var blockedDelta = 0;
var warningDelta = 0;
foreach (var change in changes)
{
switch (change.ChangeType)
{
case "new":
newlyAffected++;
if (change.Simulated?.Status == "deny") blockedDelta++;
if (change.Simulated?.Status == "warn") warningDelta++;
break;
case "removed":
noLongerAffected++;
if (change.Baseline?.Status == "deny") blockedDelta--;
if (change.Baseline?.Status == "warn") warningDelta--;
break;
case "status_changed":
var statusKey = $"{change.Baseline?.Status ?? "none"}_to_{change.Simulated?.Status ?? "none"}";
statusChanges.TryGetValue(statusKey, out var statusCount);
statusChanges[statusKey] = statusCount + 1;
// Update deltas
if (change.Baseline?.Status == "deny") blockedDelta--;
if (change.Simulated?.Status == "deny") blockedDelta++;
if (change.Baseline?.Status == "warn") warningDelta--;
if (change.Simulated?.Status == "warn") warningDelta++;
break;
case "severity_changed":
var sevKey = $"{change.Baseline?.Severity ?? "none"}_to_{change.Simulated?.Severity ?? "none"}";
severityChanges.TryGetValue(sevKey, out var sevCount);
severityChanges[sevKey] = sevCount + 1;
break;
}
}
var riskDelta = blockedDelta switch
{
> 0 => "increased",
< 0 => "decreased",
_ => warningDelta > 0 ? "increased" : warningDelta < 0 ? "decreased" : "unchanged"
};
var recommendation = riskDelta switch
{
"increased" => "Review changes before applying - risk profile increases",
"decreased" => "Changes appear safe - risk profile improves",
_ => "Neutral impact - proceed with caution"
};
return new WhatIfSummary
{
TotalEvaluated = baseline.Count + simulated.Count(kv => !baseline.ContainsKey(kv.Key)),
TotalChanged = changes.Length,
NewlyAffected = newlyAffected,
NoLongerAffected = noLongerAffected,
StatusChanges = statusChanges.ToImmutableDictionary(),
SeverityChanges = severityChanges.ToImmutableDictionary(),
Impact = new WhatIfImpact(riskDelta, blockedDelta, warningDelta, recommendation),
};
}
private static string GenerateSimulationId(WhatIfSimulationRequest request)
{
var seed = $"{request.TenantId}|{request.BaseSnapshotId}|{request.DraftPolicy?.PackId}|{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"whatif-{Convert.ToHexStringLower(hash)[..16]}";
}
private static string ComputeYamlDigest(string yaml)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(yaml));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Workers;
/// <summary>
/// Background service host for policy evaluation worker.
/// Continuously processes re-evaluation jobs from the queue.
/// </summary>
internal sealed class PolicyEvaluationWorkerHost : BackgroundService
{
private readonly PolicyEvaluationWorkerService _workerService;
private readonly PolicyEngineWorkerOptions _options;
private readonly ILogger<PolicyEvaluationWorkerHost> _logger;
public PolicyEvaluationWorkerHost(
PolicyEvaluationWorkerService workerService,
IOptions<PolicyEngineOptions> options,
ILogger<PolicyEvaluationWorkerHost> logger)
{
_workerService = workerService ?? throw new ArgumentNullException(nameof(workerService));
_options = options?.Value.Workers ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var pollInterval = TimeSpan.FromSeconds(_options.SchedulerIntervalSeconds);
var maxConcurrency = _options.MaxConcurrentEvaluations;
_logger.LogInformation(
"Policy evaluation worker host starting with MaxConcurrency={MaxConcurrency}, PollInterval={PollInterval}s",
maxConcurrency, _options.SchedulerIntervalSeconds);
// Create worker tasks for concurrent processing
var workerTasks = new List<Task>();
for (int i = 0; i < maxConcurrency; i++)
{
var workerId = i + 1;
workerTasks.Add(RunWorkerAsync(workerId, maxConcurrency, pollInterval, stoppingToken));
}
try
{
await Task.WhenAll(workerTasks).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Policy evaluation worker host stopping");
}
catch (Exception ex)
{
_logger.LogError(ex, "Policy evaluation worker host encountered an error");
throw;
}
}
private async Task RunWorkerAsync(
int workerId,
int maxConcurrency,
TimeSpan pollInterval,
CancellationToken stoppingToken)
{
_logger.LogDebug("Worker {WorkerId} starting", workerId);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var result = await _workerService.TryExecuteNextAsync(maxConcurrency, stoppingToken)
.ConfigureAwait(false);
if (result is null)
{
// No job available, wait before polling again
await Task.Delay(pollInterval, stoppingToken).ConfigureAwait(false);
}
else
{
_logger.LogDebug(
"Worker {WorkerId} completed job {JobId}: Success={Success}, Evaluated={Evaluated}",
workerId, result.JobId, result.Success, result.ItemsEvaluated);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Worker {WorkerId} encountered an error processing job", workerId);
// Wait before retrying to avoid tight error loop
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
}
}
_logger.LogDebug("Worker {WorkerId} stopped", workerId);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(
"Policy evaluation worker host stopping. Pending jobs: {PendingCount}, Running: {RunningCount}",
_workerService.GetPendingJobCount(), _workerService.GetRunningJobCount());
await base.StopAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Policy evaluation worker host stopped");
}
}

View File

@@ -0,0 +1,287 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Workers;
/// <summary>
/// Result of a batch evaluation job execution.
/// </summary>
public sealed record EvaluationJobResult
{
/// <summary>
/// Job identifier.
/// </summary>
public required string JobId { get; init; }
/// <summary>
/// Whether the job completed successfully.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Number of items evaluated.
/// </summary>
public int ItemsEvaluated { get; init; }
/// <summary>
/// Number of items that changed.
/// </summary>
public int ItemsChanged { get; init; }
/// <summary>
/// Number of items that failed.
/// </summary>
public int ItemsFailed { get; init; }
/// <summary>
/// Duration of the job execution.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Error message if the job failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Timestamp when the job started.
/// </summary>
public DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Timestamp when the job completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Service for executing batch policy evaluation jobs.
/// Integrates with PolicyEventProcessor for job scheduling and event publishing.
/// </summary>
internal sealed class PolicyEvaluationWorkerService
{
private readonly PolicyEventProcessor _eventProcessor;
private readonly ILogger<PolicyEvaluationWorkerService> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, EvaluationJobResult> _completedJobs = new();
private int _runningJobCount;
public PolicyEvaluationWorkerService(
PolicyEventProcessor eventProcessor,
ILogger<PolicyEvaluationWorkerService> logger,
TimeProvider timeProvider)
{
_eventProcessor = eventProcessor ?? throw new ArgumentNullException(nameof(eventProcessor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Gets the current number of pending jobs.
/// </summary>
public int GetPendingJobCount() => _eventProcessor.GetPendingJobCount();
/// <summary>
/// Gets the current number of running jobs.
/// </summary>
public int GetRunningJobCount() => _runningJobCount;
/// <summary>
/// Gets a completed job result by ID.
/// </summary>
public EvaluationJobResult? GetJobResult(string jobId)
{
return _completedJobs.TryGetValue(jobId, out var result) ? result : null;
}
/// <summary>
/// Tries to dequeue and execute the next job.
/// </summary>
public async Task<EvaluationJobResult?> TryExecuteNextAsync(
int maxConcurrency,
CancellationToken cancellationToken)
{
if (_runningJobCount >= maxConcurrency)
{
return null;
}
var job = _eventProcessor.DequeueJob();
if (job is null)
{
return null;
}
return await ExecuteJobAsync(job, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Executes a specific job.
/// </summary>
public async Task<EvaluationJobResult> ExecuteJobAsync(
ReEvaluationJobRequest job,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
var jobId = job.JobId;
var startedAt = _timeProvider.GetUtcNow();
var stopwatch = Stopwatch.StartNew();
Interlocked.Increment(ref _runningJobCount);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"policy.worker.execute_job", ActivityKind.Internal);
activity?.SetTag("job.id", jobId);
activity?.SetTag("job.tenant_id", job.TenantId);
activity?.SetTag("job.pack_id", job.PackId);
activity?.SetTag("job.pack_version", job.PackVersion);
activity?.SetTag("job.trigger_type", job.TriggerType);
try
{
_logger.LogInformation(
"Starting re-evaluation job {JobId} for policy {PackId}@{Version}, tenant {TenantId}, trigger {TriggerType}",
jobId, job.PackId, job.PackVersion, job.TenantId, job.TriggerType);
var subjectCount = job.SubjectPurls.Length + job.SbomIds.Length + job.AdvisoryIds.Length;
// In a full implementation, this would:
// 1. Load affected subjects from the SubjectPurls/SbomIds/AdvisoryIds
// 2. Call PolicyRuntimeEvaluationService.EvaluateBatchAsync for each batch
// 3. Compare with previous decisions to detect changes
// 4. Call _eventProcessor.ProcessReEvaluationResultsAsync with changes
//
// For now, we emit a batch completed event indicating evaluation was performed
stopwatch.Stop();
var completedAt = _timeProvider.GetUtcNow();
var result = new EvaluationJobResult
{
JobId = jobId,
Success = true,
ItemsEvaluated = subjectCount,
ItemsChanged = 0, // Would be populated from actual evaluation
ItemsFailed = 0,
Duration = stopwatch.Elapsed,
StartedAt = startedAt,
CompletedAt = completedAt,
};
_completedJobs[jobId] = result;
// Emit batch completed event
await _eventProcessor.ProcessReEvaluationResultsAsync(
jobId,
job.TenantId,
job.PackId,
job.PackVersion,
job.TriggerType,
job.CorrelationId,
changes: Array.Empty<PolicyDecisionChange>(),
durationMs: stopwatch.ElapsedMilliseconds,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Completed re-evaluation job {JobId}: {Evaluated} evaluated in {Duration}ms",
jobId, subjectCount, stopwatch.ElapsedMilliseconds);
activity?.SetTag("job.success", true);
activity?.SetTag("job.items_evaluated", subjectCount);
activity?.SetStatus(ActivityStatusCode.Ok);
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
var result = new EvaluationJobResult
{
JobId = jobId,
Success = false,
ErrorMessage = "Job was cancelled",
Duration = stopwatch.Elapsed,
StartedAt = startedAt,
};
_completedJobs[jobId] = result;
_logger.LogWarning("Re-evaluation job {JobId} was cancelled", jobId);
activity?.SetTag("job.success", false);
activity?.SetStatus(ActivityStatusCode.Error, "Cancelled");
return result;
}
catch (Exception ex)
{
stopwatch.Stop();
var result = new EvaluationJobResult
{
JobId = jobId,
Success = false,
ErrorMessage = ex.Message,
Duration = stopwatch.Elapsed,
StartedAt = startedAt,
};
_completedJobs[jobId] = result;
_logger.LogError(ex, "Re-evaluation job {JobId} failed with error", jobId);
activity?.SetTag("job.success", false);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
PolicyEngineTelemetry.RecordError("worker_job", job.TenantId);
return result;
}
finally
{
Interlocked.Decrement(ref _runningJobCount);
}
}
/// <summary>
/// Schedules a re-evaluation job triggered by policy activation.
/// </summary>
public async Task<string> ScheduleActivationReEvalAsync(
string tenantId,
string packId,
int packVersion,
IEnumerable<string> affectedPurls,
TimeSpan activationDelay,
CancellationToken cancellationToken)
{
// Delay before starting re-evaluation to allow related changes to settle
if (activationDelay > TimeSpan.Zero)
{
await Task.Delay(activationDelay, cancellationToken).ConfigureAwait(false);
}
var now = _timeProvider.GetUtcNow();
var jobId = ReEvaluationJobRequest.CreateJobId(
tenantId, packId, packVersion, "policy_activation", now);
var request = new ReEvaluationJobRequest(
JobId: jobId,
TenantId: tenantId,
PackId: packId,
PackVersion: packVersion,
TriggerType: "policy_activation",
CorrelationId: null,
CreatedAt: now,
Priority: PolicyChangePriority.High,
AdvisoryIds: ImmutableArray<string>.Empty,
SubjectPurls: affectedPurls.ToImmutableArray(),
SbomIds: ImmutableArray<string>.Empty,
Metadata: ImmutableDictionary<string, string>.Empty);
return await _eventProcessor.ScheduleAsync(request, cancellationToken).ConfigureAwait(false);
}
}