Files
git.stella-ops.org/src/Policy/__Libraries/StellaOps.Policy/PolicyExplanation.cs
StellaOps Bot 3b96b2e3ea
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
up
2025-11-27 23:45:09 +02:00

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