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
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:
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"}";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user