up
This commit is contained in:
@@ -17,6 +17,32 @@ public sealed record PolicyExplanation(
|
||||
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());
|
||||
|
||||
@@ -25,6 +51,28 @@ public sealed record PolicyExplanation(
|
||||
|
||||
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>
|
||||
@@ -45,4 +93,202 @@ public sealed record PolicyExplanationNode(
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user