up
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

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -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();
}

View File

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

View File

@@ -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, "/");
}
}

View File

@@ -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>