up
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IPolicyExplanationStore"/> for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPolicyExplanationStore : IPolicyExplanationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyExplanationRecord> _records = new(StringComparer.Ordinal);
|
||||
private readonly int _maxRecords;
|
||||
|
||||
public InMemoryPolicyExplanationStore(int maxRecords = 10000)
|
||||
{
|
||||
_maxRecords = maxRecords;
|
||||
}
|
||||
|
||||
public Task SaveAsync(PolicyExplanationRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
_records[record.Id] = record;
|
||||
|
||||
// Trim old records if over limit
|
||||
while (_records.Count > _maxRecords)
|
||||
{
|
||||
var oldest = _records.Values
|
||||
.OrderBy(r => r.EvaluatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (oldest is not null)
|
||||
{
|
||||
_records.TryRemove(oldest.Id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PolicyExplanationRecord?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_records.TryGetValue(id, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyExplanationRecord>> GetByFindingIdAsync(
|
||||
string findingId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = _records.Values
|
||||
.Where(r => r.FindingId.Equals(findingId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.EvaluatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyExplanationRecord>>(results);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyExplanationRecord>> GetByCorrelationIdAsync(
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = _records.Values
|
||||
.Where(r => r.CorrelationId?.Equals(correlationId, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.OrderByDescending(r => r.EvaluatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyExplanationRecord>>(results);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyExplanationRecord>> QueryAsync(
|
||||
PolicyExplanationQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<PolicyExplanationRecord> results = _records.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PolicyId))
|
||||
{
|
||||
results = results.Where(r => r.PolicyId.Equals(query.PolicyId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
results = results.Where(r => r.TenantId?.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase) == true);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Decision))
|
||||
{
|
||||
results = results.Where(r => r.Decision.Equals(query.Decision, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.EvaluatedAt >= query.Since.Value);
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.EvaluatedAt <= query.Until.Value);
|
||||
}
|
||||
|
||||
var list = results
|
||||
.OrderByDescending(r => r.EvaluatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyExplanationRecord>>(list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count of records in the store.
|
||||
/// </summary>
|
||||
public int Count => _records.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Clears all records from the store.
|
||||
/// </summary>
|
||||
public void Clear() => _records.Clear();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -326,15 +326,15 @@ public static class RiskProfileDiagnostics
|
||||
return recommendations.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<RiskProfileIssue> ExtractSchemaErrors(ValidationResults results)
|
||||
private static IEnumerable<RiskProfileIssue> ExtractSchemaErrors(EvaluationResults results)
|
||||
{
|
||||
if (results.Details != null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (detail.HasErrors)
|
||||
if (!detail.IsValid && detail.Errors != null)
|
||||
{
|
||||
foreach (var error in detail.Errors ?? [])
|
||||
foreach (var error in detail.Errors)
|
||||
{
|
||||
yield return RiskProfileIssue.Error(
|
||||
"RISK003",
|
||||
@@ -344,9 +344,10 @@ public static class RiskProfileDiagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(results.Message))
|
||||
else if (!results.IsValid)
|
||||
{
|
||||
yield return RiskProfileIssue.Error("RISK003", results.Message, "/");
|
||||
var errorMessage = results.Errors?.FirstOrDefault().Value ?? "Schema validation failed";
|
||||
yield return RiskProfileIssue.Error("RISK003", errorMessage, "/");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -7,6 +8,102 @@ using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Defines merge strategy for a field.
|
||||
/// </summary>
|
||||
public enum FieldMergeStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Overlay value replaces base value.
|
||||
/// </summary>
|
||||
OverlayWins,
|
||||
|
||||
/// <summary>
|
||||
/// Base value is kept unless overlay explicitly sets.
|
||||
/// </summary>
|
||||
BaseWins,
|
||||
|
||||
/// <summary>
|
||||
/// Values are merged (for collections).
|
||||
/// </summary>
|
||||
Merge,
|
||||
|
||||
/// <summary>
|
||||
/// Union of base and overlay (for arrays).
|
||||
/// </summary>
|
||||
Union,
|
||||
|
||||
/// <summary>
|
||||
/// Higher value wins (for numeric comparisons).
|
||||
/// </summary>
|
||||
HigherWins,
|
||||
|
||||
/// <summary>
|
||||
/// Lower value wins (for numeric comparisons).
|
||||
/// </summary>
|
||||
LowerWins,
|
||||
|
||||
/// <summary>
|
||||
/// More restrictive value wins (deny > allow).
|
||||
/// </summary>
|
||||
MoreRestrictive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Precedence level for layering sources.
|
||||
/// </summary>
|
||||
public enum LayerPrecedence
|
||||
{
|
||||
/// <summary>
|
||||
/// Global/system defaults (lowest precedence).
|
||||
/// </summary>
|
||||
Global = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Organization-level overrides.
|
||||
/// </summary>
|
||||
Organization = 100,
|
||||
|
||||
/// <summary>
|
||||
/// Project-level overrides.
|
||||
/// </summary>
|
||||
Project = 200,
|
||||
|
||||
/// <summary>
|
||||
/// Environment-level overrides.
|
||||
/// </summary>
|
||||
Environment = 300,
|
||||
|
||||
/// <summary>
|
||||
/// Exception-level overrides (highest precedence).
|
||||
/// </summary>
|
||||
Exception = 400
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for field-level precedence.
|
||||
/// </summary>
|
||||
public sealed record FieldPrecedenceConfig(
|
||||
string FieldPath,
|
||||
FieldMergeStrategy Strategy,
|
||||
IReadOnlyDictionary<LayerPrecedence, int>? LayerWeights = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a layered merge operation.
|
||||
/// </summary>
|
||||
public sealed record LayeredMergeResult(
|
||||
string MergedJson,
|
||||
string ContentHash,
|
||||
IReadOnlyList<LayerContribution> Contributions);
|
||||
|
||||
/// <summary>
|
||||
/// Contribution from a layer to the merged result.
|
||||
/// </summary>
|
||||
public sealed record LayerContribution(
|
||||
string LayerId,
|
||||
LayerPrecedence Precedence,
|
||||
IReadOnlyList<string> FieldsContributed);
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic layering/override semantics for SPL (Stella Policy Language) documents.
|
||||
/// Overlay statements replace base statements with the same <c>id</c>; metadata labels/annotations merge with overlay precedence.
|
||||
@@ -20,6 +117,23 @@ public static class SplLayeringEngine
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default field precedence matrix.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, FieldPrecedenceConfig> DefaultFieldPrecedence =
|
||||
new Dictionary<string, FieldPrecedenceConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["spec.defaultEffect"] = new("spec.defaultEffect", FieldMergeStrategy.MoreRestrictive),
|
||||
["spec.statements"] = new("spec.statements", FieldMergeStrategy.Merge),
|
||||
["spec.statements.*.effect"] = new("spec.statements.*.effect", FieldMergeStrategy.MoreRestrictive),
|
||||
["spec.statements.*.match.actions"] = new("spec.statements.*.match.actions", FieldMergeStrategy.Union),
|
||||
["spec.statements.*.match.conditions"] = new("spec.statements.*.match.conditions", FieldMergeStrategy.Union),
|
||||
["spec.statements.*.match.weighting.reachability"] = new("spec.statements.*.match.weighting.reachability", FieldMergeStrategy.LowerWins),
|
||||
["spec.statements.*.match.weighting.exploitability"] = new("spec.statements.*.match.weighting.exploitability", FieldMergeStrategy.LowerWins),
|
||||
["metadata.labels"] = new("metadata.labels", FieldMergeStrategy.Merge),
|
||||
["metadata.annotations"] = new("metadata.annotations", FieldMergeStrategy.Merge),
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Merge two SPL documents and return canonical JSON (sorted properties/statements/actions/conditions).
|
||||
/// </summary>
|
||||
@@ -32,6 +146,291 @@ public static class SplLayeringEngine
|
||||
return Encoding.UTF8.GetString(merged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple SPL layers in precedence order and returns the result with contribution tracking.
|
||||
/// </summary>
|
||||
/// <param name="layers">Layers ordered by precedence (lowest first).</param>
|
||||
/// <param name="fieldPrecedence">Optional field precedence configuration.</param>
|
||||
/// <returns>Merged result with contribution information.</returns>
|
||||
public static LayeredMergeResult MergeLayers(
|
||||
IReadOnlyList<(string LayerId, string PolicyJson, LayerPrecedence Precedence)> layers,
|
||||
IReadOnlyDictionary<string, FieldPrecedenceConfig>? fieldPrecedence = null)
|
||||
{
|
||||
if (layers == null || layers.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one layer is required.", nameof(layers));
|
||||
}
|
||||
|
||||
var config = fieldPrecedence ?? DefaultFieldPrecedence;
|
||||
var contributions = new List<LayerContribution>();
|
||||
|
||||
// Sort layers by precedence
|
||||
var sortedLayers = layers.OrderBy(l => (int)l.Precedence).ToList();
|
||||
|
||||
// Start with the first layer
|
||||
var current = sortedLayers[0].PolicyJson;
|
||||
var currentContributedFields = TrackContributedFields(current);
|
||||
contributions.Add(new LayerContribution(
|
||||
sortedLayers[0].LayerId,
|
||||
sortedLayers[0].Precedence,
|
||||
currentContributedFields));
|
||||
|
||||
// Merge each subsequent layer
|
||||
for (var i = 1; i < sortedLayers.Count; i++)
|
||||
{
|
||||
var layer = sortedLayers[i];
|
||||
var layerFields = TrackContributedFields(layer.PolicyJson);
|
||||
|
||||
// Apply field-level precedence
|
||||
var mergedJson = MergeWithStrategy(current, layer.PolicyJson, config);
|
||||
current = mergedJson;
|
||||
|
||||
contributions.Add(new LayerContribution(
|
||||
layer.LayerId,
|
||||
layer.Precedence,
|
||||
layerFields));
|
||||
}
|
||||
|
||||
var canonical = SplCanonicalizer.CanonicalizeToString(current);
|
||||
var contentHash = SplCanonicalizer.ComputeDigest(canonical);
|
||||
|
||||
return new LayeredMergeResult(
|
||||
MergedJson: canonical,
|
||||
ContentHash: contentHash,
|
||||
Contributions: contributions.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two policies applying field-level strategy configuration.
|
||||
/// </summary>
|
||||
public static string MergeWithStrategy(
|
||||
string basePolicyJson,
|
||||
string overlayPolicyJson,
|
||||
IReadOnlyDictionary<string, FieldPrecedenceConfig>? fieldPrecedence = null)
|
||||
{
|
||||
if (basePolicyJson is null) throw new ArgumentNullException(nameof(basePolicyJson));
|
||||
if (overlayPolicyJson is null) throw new ArgumentNullException(nameof(overlayPolicyJson));
|
||||
|
||||
var config = fieldPrecedence ?? DefaultFieldPrecedence;
|
||||
|
||||
using var baseDoc = JsonDocument.Parse(basePolicyJson, DocumentOptions);
|
||||
using var overlayDoc = JsonDocument.Parse(overlayPolicyJson, DocumentOptions);
|
||||
|
||||
var result = MergeWithStrategyInternal(baseDoc.RootElement, overlayDoc.RootElement, "", config);
|
||||
|
||||
var json = result.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
return SplCanonicalizer.CanonicalizeToString(json);
|
||||
}
|
||||
|
||||
private static JsonNode MergeWithStrategyInternal(
|
||||
JsonElement baseElement,
|
||||
JsonElement overlayElement,
|
||||
string currentPath,
|
||||
IReadOnlyDictionary<string, FieldPrecedenceConfig> config)
|
||||
{
|
||||
// Look up strategy for current path
|
||||
var strategy = GetStrategyForPath(currentPath, config);
|
||||
|
||||
// Handle scalar comparison strategies
|
||||
if (strategy == FieldMergeStrategy.MoreRestrictive)
|
||||
{
|
||||
return ApplyMoreRestrictive(baseElement, overlayElement);
|
||||
}
|
||||
|
||||
if (strategy == FieldMergeStrategy.HigherWins)
|
||||
{
|
||||
return ApplyHigherWins(baseElement, overlayElement);
|
||||
}
|
||||
|
||||
if (strategy == FieldMergeStrategy.LowerWins)
|
||||
{
|
||||
return ApplyLowerWins(baseElement, overlayElement);
|
||||
}
|
||||
|
||||
if (strategy == FieldMergeStrategy.BaseWins)
|
||||
{
|
||||
return JsonNode.Parse(baseElement.GetRawText())!;
|
||||
}
|
||||
|
||||
// Default: overlay wins for scalars, merge for objects/arrays
|
||||
if (baseElement.ValueKind != JsonValueKind.Object && baseElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return JsonNode.Parse(overlayElement.GetRawText())!;
|
||||
}
|
||||
|
||||
if (baseElement.ValueKind == JsonValueKind.Array && overlayElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
if (strategy == FieldMergeStrategy.Union)
|
||||
{
|
||||
return MergeArraysUnion(baseElement, overlayElement);
|
||||
}
|
||||
// Default array merge: overlay replaces base
|
||||
return JsonNode.Parse(overlayElement.GetRawText())!;
|
||||
}
|
||||
|
||||
if (baseElement.ValueKind == JsonValueKind.Object && overlayElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var result = new JsonObject();
|
||||
|
||||
// Add all base properties
|
||||
foreach (var prop in baseElement.EnumerateObject())
|
||||
{
|
||||
var childPath = string.IsNullOrEmpty(currentPath) ? prop.Name : $"{currentPath}.{prop.Name}";
|
||||
|
||||
if (overlayElement.TryGetProperty(prop.Name, out var overlayProp))
|
||||
{
|
||||
result[prop.Name] = MergeWithStrategyInternal(prop.Value, overlayProp, childPath, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
// Add overlay-only properties
|
||||
foreach (var prop in overlayElement.EnumerateObject())
|
||||
{
|
||||
if (!baseElement.TryGetProperty(prop.Name, out _))
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Type mismatch: overlay wins
|
||||
return JsonNode.Parse(overlayElement.GetRawText())!;
|
||||
}
|
||||
|
||||
private static FieldMergeStrategy GetStrategyForPath(
|
||||
string path,
|
||||
IReadOnlyDictionary<string, FieldPrecedenceConfig> config)
|
||||
{
|
||||
// Direct match
|
||||
if (config.TryGetValue(path, out var direct))
|
||||
{
|
||||
return direct.Strategy;
|
||||
}
|
||||
|
||||
// Wildcard match (e.g., "spec.statements.*.effect")
|
||||
var pathParts = path.Split('.');
|
||||
for (var i = pathParts.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var wildcardPath = string.Join(".", pathParts.Take(i).Append("*").Concat(pathParts.Skip(i + 1)));
|
||||
if (config.TryGetValue(wildcardPath, out var wildcard))
|
||||
{
|
||||
return wildcard.Strategy;
|
||||
}
|
||||
}
|
||||
|
||||
return FieldMergeStrategy.OverlayWins;
|
||||
}
|
||||
|
||||
private static JsonNode ApplyMoreRestrictive(JsonElement baseElement, JsonElement overlayElement)
|
||||
{
|
||||
var baseStr = baseElement.ValueKind == JsonValueKind.String
|
||||
? baseElement.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
var overlayStr = overlayElement.ValueKind == JsonValueKind.String
|
||||
? overlayElement.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
|
||||
// deny > allow for effects
|
||||
if (baseStr == "deny" || overlayStr == "deny")
|
||||
{
|
||||
return JsonValue.Create("deny");
|
||||
}
|
||||
|
||||
// Overlay wins if neither is deny
|
||||
return JsonNode.Parse(overlayElement.GetRawText())!;
|
||||
}
|
||||
|
||||
private static JsonNode ApplyHigherWins(JsonElement baseElement, JsonElement overlayElement)
|
||||
{
|
||||
if (baseElement.TryGetDouble(out var baseNum) && overlayElement.TryGetDouble(out var overlayNum))
|
||||
{
|
||||
return JsonValue.Create(Math.Max(baseNum, overlayNum));
|
||||
}
|
||||
|
||||
return JsonNode.Parse(overlayElement.GetRawText())!;
|
||||
}
|
||||
|
||||
private static JsonNode ApplyLowerWins(JsonElement baseElement, JsonElement overlayElement)
|
||||
{
|
||||
if (baseElement.TryGetDouble(out var baseNum) && overlayElement.TryGetDouble(out var overlayNum))
|
||||
{
|
||||
return JsonValue.Create(Math.Min(baseNum, overlayNum));
|
||||
}
|
||||
|
||||
return JsonNode.Parse(overlayElement.GetRawText())!;
|
||||
}
|
||||
|
||||
private static JsonArray MergeArraysUnion(JsonElement baseArray, JsonElement overlayArray)
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
var result = new JsonArray();
|
||||
|
||||
foreach (var item in baseArray.EnumerateArray())
|
||||
{
|
||||
var key = item.GetRawText();
|
||||
if (seen.Add(key))
|
||||
{
|
||||
result.Add(JsonNode.Parse(key));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in overlayArray.EnumerateArray())
|
||||
{
|
||||
var key = item.GetRawText();
|
||||
if (seen.Add(key))
|
||||
{
|
||||
result.Add(JsonNode.Parse(key));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TrackContributedFields(string policyJson)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(policyJson, DocumentOptions);
|
||||
CollectFieldPaths(doc.RootElement, "", fields);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return fields.AsReadOnly();
|
||||
}
|
||||
|
||||
private static void CollectFieldPaths(JsonElement element, string prefix, List<string> fields)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
var path = string.IsNullOrEmpty(prefix) ? prop.Name : $"{prefix}.{prop.Name}";
|
||||
fields.Add(path);
|
||||
CollectFieldPaths(prop.Value, path, fields);
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var idx = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
CollectFieldPaths(item, $"{prefix}[{idx}]", fields);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge two SPL documents and return canonical UTF-8 bytes.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user