295 lines
12 KiB
C#
295 lines
12 KiB
C#
using System.Collections.Immutable;
|
|
|
|
namespace StellaOps.Policy;
|
|
|
|
/// <summary>
|
|
/// Structured explanation describing how a policy decision was reached.
|
|
/// </summary>
|
|
/// <param name="FindingId">Identifier of the evaluated finding.</param>
|
|
/// <param name="Decision">Final verdict status (e.g., Allow, Block, Warned).</param>
|
|
/// <param name="RuleName">Name of the rule that matched, if any.</param>
|
|
/// <param name="Reason">Human-readable summary.</param>
|
|
/// <param name="Nodes">Tree of evaluated nodes (rule, match, action, penalties, quieting, unknown confidence).</param>
|
|
public sealed record PolicyExplanation(
|
|
string FindingId,
|
|
PolicyVerdictStatus Decision,
|
|
string? RuleName,
|
|
string Reason,
|
|
ImmutableArray<PolicyExplanationNode> Nodes)
|
|
{
|
|
/// <summary>
|
|
/// Detailed rule hit information for audit trails.
|
|
/// </summary>
|
|
public ImmutableArray<RuleHit> RuleHits { get; init; } = ImmutableArray<RuleHit>.Empty;
|
|
|
|
/// <summary>
|
|
/// Input signals that were evaluated during decision making.
|
|
/// </summary>
|
|
public ImmutableDictionary<string, object?> EvaluatedInputs { get; init; } =
|
|
ImmutableDictionary<string, object?>.Empty;
|
|
|
|
/// <summary>
|
|
/// Timestamp when this explanation was generated (UTC).
|
|
/// </summary>
|
|
public DateTimeOffset? EvaluatedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Policy version that was used for evaluation.
|
|
/// </summary>
|
|
public string? PolicyVersion { get; init; }
|
|
|
|
/// <summary>
|
|
/// Correlation ID for tracing across systems.
|
|
/// </summary>
|
|
public string? CorrelationId { get; init; }
|
|
|
|
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
|
new(findingId, PolicyVerdictStatus.Pass, ruleName, reason, nodes.ToImmutableArray());
|
|
|
|
public static PolicyExplanation Block(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
|
new(findingId, PolicyVerdictStatus.Blocked, ruleName, reason, nodes.ToImmutableArray());
|
|
|
|
public static PolicyExplanation Warn(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
|
new(findingId, PolicyVerdictStatus.Warned, ruleName, reason, nodes.ToImmutableArray());
|
|
|
|
/// <summary>
|
|
/// Creates an explanation with full context for persistence.
|
|
/// </summary>
|
|
public static PolicyExplanation Create(
|
|
string findingId,
|
|
PolicyVerdictStatus decision,
|
|
string? ruleName,
|
|
string reason,
|
|
IEnumerable<PolicyExplanationNode> nodes,
|
|
IEnumerable<RuleHit>? ruleHits = null,
|
|
IDictionary<string, object?>? inputs = null,
|
|
string? policyVersion = null,
|
|
string? correlationId = null) =>
|
|
new(findingId, decision, ruleName, reason, nodes.ToImmutableArray())
|
|
{
|
|
RuleHits = ruleHits?.ToImmutableArray() ?? ImmutableArray<RuleHit>.Empty,
|
|
EvaluatedInputs = inputs?.ToImmutableDictionary() ?? ImmutableDictionary<string, object?>.Empty,
|
|
EvaluatedAt = DateTimeOffset.UtcNow,
|
|
PolicyVersion = policyVersion,
|
|
CorrelationId = correlationId
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// A single explanation node with optional children to capture evaluation breadcrumbs.
|
|
/// </summary>
|
|
/// <param name="Kind">Short classifier (e.g., "rule", "match", "penalty", "quiet", "unknown").</param>
|
|
/// <param name="Label">Human-readable label.</param>
|
|
/// <param name="Detail">Optional detail (numeric or string rendered as text).</param>
|
|
/// <param name="Children">Nested explanation nodes.</param>
|
|
public sealed record PolicyExplanationNode(
|
|
string Kind,
|
|
string Label,
|
|
string? Detail,
|
|
ImmutableArray<PolicyExplanationNode> Children)
|
|
{
|
|
public static PolicyExplanationNode Leaf(string kind, string label, string? detail = null) =>
|
|
new(kind, label, detail, ImmutableArray<PolicyExplanationNode>.Empty);
|
|
|
|
public static PolicyExplanationNode Branch(string kind, string label, string? detail = null, params PolicyExplanationNode[] children) =>
|
|
new(kind, label, detail, children.ToImmutableArray());
|
|
|
|
/// <summary>
|
|
/// Creates a rule evaluation node showing match result.
|
|
/// </summary>
|
|
public static PolicyExplanationNode RuleEvaluation(string ruleId, bool matched, string? reason = null) =>
|
|
Leaf("rule_eval", $"Rule '{ruleId}' {(matched ? "matched" : "did not match")}", reason);
|
|
|
|
/// <summary>
|
|
/// Creates an input signal node showing what value was evaluated.
|
|
/// </summary>
|
|
public static PolicyExplanationNode InputSignal(string signalName, object? value) =>
|
|
Leaf("input", signalName, value?.ToString());
|
|
|
|
/// <summary>
|
|
/// Creates a condition evaluation node.
|
|
/// </summary>
|
|
public static PolicyExplanationNode ConditionEvaluation(string field, string op, object? expected, object? actual, bool satisfied) =>
|
|
Leaf("condition", $"{field} {op} {expected}", $"actual={actual}, satisfied={satisfied}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a rule that was evaluated and its match result.
|
|
/// </summary>
|
|
/// <param name="RuleId">Unique identifier of the rule.</param>
|
|
/// <param name="RuleName">Human-readable name of the rule.</param>
|
|
/// <param name="Matched">Whether the rule matched the input.</param>
|
|
/// <param name="Effect">The effect that would be applied if matched (allow/deny).</param>
|
|
/// <param name="Priority">Rule priority/precedence for conflict resolution.</param>
|
|
/// <param name="MatchedConditions">Conditions that were satisfied.</param>
|
|
/// <param name="FailedConditions">Conditions that were not satisfied.</param>
|
|
public sealed record RuleHit(
|
|
string RuleId,
|
|
string? RuleName,
|
|
bool Matched,
|
|
string Effect,
|
|
int Priority,
|
|
ImmutableArray<ConditionResult> MatchedConditions,
|
|
ImmutableArray<ConditionResult> FailedConditions)
|
|
{
|
|
/// <summary>
|
|
/// Creates a successful rule hit.
|
|
/// </summary>
|
|
public static RuleHit Match(
|
|
string ruleId,
|
|
string? ruleName,
|
|
string effect,
|
|
int priority = 0,
|
|
IEnumerable<ConditionResult>? matchedConditions = null) =>
|
|
new(ruleId, ruleName, true, effect, priority,
|
|
matchedConditions?.ToImmutableArray() ?? ImmutableArray<ConditionResult>.Empty,
|
|
ImmutableArray<ConditionResult>.Empty);
|
|
|
|
/// <summary>
|
|
/// Creates a rule miss (no match).
|
|
/// </summary>
|
|
public static RuleHit Miss(
|
|
string ruleId,
|
|
string? ruleName,
|
|
string effect,
|
|
int priority = 0,
|
|
IEnumerable<ConditionResult>? failedConditions = null) =>
|
|
new(ruleId, ruleName, false, effect, priority,
|
|
ImmutableArray<ConditionResult>.Empty,
|
|
failedConditions?.ToImmutableArray() ?? ImmutableArray<ConditionResult>.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of evaluating a single condition.
|
|
/// </summary>
|
|
/// <param name="Field">The field/path that was evaluated.</param>
|
|
/// <param name="Operator">The comparison operator used.</param>
|
|
/// <param name="ExpectedValue">The expected value from the rule.</param>
|
|
/// <param name="ActualValue">The actual value from the input.</param>
|
|
/// <param name="Satisfied">Whether the condition was satisfied.</param>
|
|
public sealed record ConditionResult(
|
|
string Field,
|
|
string Operator,
|
|
object? ExpectedValue,
|
|
object? ActualValue,
|
|
bool Satisfied);
|
|
|
|
/// <summary>
|
|
/// Persistence-ready explanation record for storage in databases or audit logs.
|
|
/// </summary>
|
|
/// <param name="Id">Unique identifier for this explanation record.</param>
|
|
/// <param name="FindingId">The finding that was evaluated.</param>
|
|
/// <param name="PolicyId">The policy that was applied.</param>
|
|
/// <param name="PolicyVersion">Version of the policy.</param>
|
|
/// <param name="Decision">Final decision status.</param>
|
|
/// <param name="Reason">Human-readable reason for the decision.</param>
|
|
/// <param name="RuleHitsJson">Serialized rule hits for storage.</param>
|
|
/// <param name="InputsJson">Serialized evaluated inputs for storage.</param>
|
|
/// <param name="ExplanationTreeJson">Serialized explanation tree for storage.</param>
|
|
/// <param name="EvaluatedAt">When the evaluation occurred.</param>
|
|
/// <param name="CorrelationId">Trace correlation ID.</param>
|
|
/// <param name="TenantId">Optional tenant identifier.</param>
|
|
/// <param name="Actor">Optional actor who triggered the evaluation.</param>
|
|
public sealed record PolicyExplanationRecord(
|
|
string Id,
|
|
string FindingId,
|
|
string PolicyId,
|
|
string PolicyVersion,
|
|
string Decision,
|
|
string Reason,
|
|
string RuleHitsJson,
|
|
string InputsJson,
|
|
string ExplanationTreeJson,
|
|
DateTimeOffset EvaluatedAt,
|
|
string? CorrelationId,
|
|
string? TenantId,
|
|
string? Actor)
|
|
{
|
|
/// <summary>
|
|
/// Creates a persistence record from an explanation.
|
|
/// </summary>
|
|
public static PolicyExplanationRecord FromExplanation(
|
|
PolicyExplanation explanation,
|
|
string policyId,
|
|
string? tenantId = null,
|
|
string? actor = null)
|
|
{
|
|
var id = $"pexp-{Guid.NewGuid():N}";
|
|
var ruleHitsJson = System.Text.Json.JsonSerializer.Serialize(explanation.RuleHits);
|
|
var inputsJson = System.Text.Json.JsonSerializer.Serialize(explanation.EvaluatedInputs);
|
|
var treeJson = System.Text.Json.JsonSerializer.Serialize(explanation.Nodes);
|
|
|
|
return new PolicyExplanationRecord(
|
|
Id: id,
|
|
FindingId: explanation.FindingId,
|
|
PolicyId: policyId,
|
|
PolicyVersion: explanation.PolicyVersion ?? "unknown",
|
|
Decision: explanation.Decision.ToString(),
|
|
Reason: explanation.Reason,
|
|
RuleHitsJson: ruleHitsJson,
|
|
InputsJson: inputsJson,
|
|
ExplanationTreeJson: treeJson,
|
|
EvaluatedAt: explanation.EvaluatedAt ?? DateTimeOffset.UtcNow,
|
|
CorrelationId: explanation.CorrelationId,
|
|
TenantId: tenantId,
|
|
Actor: actor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store interface for persisting and retrieving policy explanations.
|
|
/// </summary>
|
|
public interface IPolicyExplanationStore
|
|
{
|
|
/// <summary>
|
|
/// Saves an explanation record.
|
|
/// </summary>
|
|
Task SaveAsync(PolicyExplanationRecord record, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Retrieves an explanation by ID.
|
|
/// </summary>
|
|
Task<PolicyExplanationRecord?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Retrieves explanations for a finding.
|
|
/// </summary>
|
|
Task<IReadOnlyList<PolicyExplanationRecord>> GetByFindingIdAsync(
|
|
string findingId,
|
|
int limit = 100,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Retrieves explanations by correlation ID.
|
|
/// </summary>
|
|
Task<IReadOnlyList<PolicyExplanationRecord>> GetByCorrelationIdAsync(
|
|
string correlationId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Queries explanations with filtering.
|
|
/// </summary>
|
|
Task<IReadOnlyList<PolicyExplanationRecord>> QueryAsync(
|
|
PolicyExplanationQuery query,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query parameters for searching explanation records.
|
|
/// </summary>
|
|
/// <param name="PolicyId">Filter by policy ID.</param>
|
|
/// <param name="TenantId">Filter by tenant ID.</param>
|
|
/// <param name="Decision">Filter by decision status.</param>
|
|
/// <param name="Since">Filter by evaluation time (minimum).</param>
|
|
/// <param name="Until">Filter by evaluation time (maximum).</param>
|
|
/// <param name="Limit">Maximum number of records to return.</param>
|
|
/// <param name="Offset">Number of records to skip.</param>
|
|
public sealed record PolicyExplanationQuery(
|
|
string? PolicyId = null,
|
|
string? TenantId = null,
|
|
string? Decision = null,
|
|
DateTimeOffset? Since = null,
|
|
DateTimeOffset? Until = null,
|
|
int Limit = 100,
|
|
int Offset = 0);
|