up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Merge;
|
||||
|
||||
/// <summary>
|
||||
/// Service for merging and resolving inheritance in risk profiles.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileMergeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a risk profile by applying inheritance from parent profiles.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to resolve.</param>
|
||||
/// <param name="profileResolver">Function to resolve parent profiles by ID.</param>
|
||||
/// <param name="maxDepth">Maximum inheritance depth to prevent cycles.</param>
|
||||
/// <returns>A fully resolved profile with inherited values merged.</returns>
|
||||
public RiskProfileModel ResolveInheritance(
|
||||
RiskProfileModel profile,
|
||||
Func<string, RiskProfileModel?> profileResolver,
|
||||
int maxDepth = 10)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
ArgumentNullException.ThrowIfNull(profileResolver);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.Extends))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
var chain = BuildInheritanceChain(profile, profileResolver, maxDepth);
|
||||
return MergeChain(chain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple profiles in order (later profiles override earlier ones).
|
||||
/// </summary>
|
||||
/// <param name="profiles">Profiles to merge, in order of precedence (first = base, last = highest priority).</param>
|
||||
/// <returns>A merged profile.</returns>
|
||||
public RiskProfileModel MergeProfiles(IEnumerable<RiskProfileModel> profiles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profiles);
|
||||
|
||||
var profileList = profiles.ToList();
|
||||
if (profileList.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one profile is required.", nameof(profiles));
|
||||
}
|
||||
|
||||
return MergeChain(profileList);
|
||||
}
|
||||
|
||||
private List<RiskProfileModel> BuildInheritanceChain(
|
||||
RiskProfileModel profile,
|
||||
Func<string, RiskProfileModel?> resolver,
|
||||
int maxDepth)
|
||||
{
|
||||
var chain = new List<RiskProfileModel>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var current = profile;
|
||||
var depth = 0;
|
||||
|
||||
while (current != null && depth < maxDepth)
|
||||
{
|
||||
if (!visited.Add(current.Id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Circular inheritance detected: profile '{current.Id}' already in chain.");
|
||||
}
|
||||
|
||||
chain.Add(current);
|
||||
depth++;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(current.Extends))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var parent = resolver(current.Extends);
|
||||
if (parent == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Parent profile '{current.Extends}' not found for profile '{current.Id}'.");
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if (depth >= maxDepth)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum inheritance depth ({maxDepth}) exceeded for profile '{profile.Id}'.");
|
||||
}
|
||||
|
||||
// Reverse so base profiles come first
|
||||
chain.Reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
private RiskProfileModel MergeChain(List<RiskProfileModel> chain)
|
||||
{
|
||||
if (chain.Count == 1)
|
||||
{
|
||||
return CloneProfile(chain[0]);
|
||||
}
|
||||
|
||||
var result = CloneProfile(chain[0]);
|
||||
|
||||
for (int i = 1; i < chain.Count; i++)
|
||||
{
|
||||
var overlay = chain[i];
|
||||
MergeInto(result, overlay);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void MergeInto(RiskProfileModel target, RiskProfileModel overlay)
|
||||
{
|
||||
// Override identity fields
|
||||
target.Id = overlay.Id;
|
||||
target.Version = overlay.Version;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(overlay.Description))
|
||||
{
|
||||
target.Description = overlay.Description;
|
||||
}
|
||||
|
||||
// Clear extends since inheritance has been resolved
|
||||
target.Extends = null;
|
||||
|
||||
// Merge signals (overlay signals replace by name, new ones are added)
|
||||
MergeSignals(target.Signals, overlay.Signals);
|
||||
|
||||
// Merge weights (overlay weights override by key)
|
||||
foreach (var kvp in overlay.Weights)
|
||||
{
|
||||
target.Weights[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Merge overrides (append overlay rules)
|
||||
MergeOverrides(target.Overrides, overlay.Overrides);
|
||||
|
||||
// Merge metadata (overlay values override by key)
|
||||
if (overlay.Metadata != null)
|
||||
{
|
||||
target.Metadata ??= new Dictionary<string, object?>();
|
||||
foreach (var kvp in overlay.Metadata)
|
||||
{
|
||||
target.Metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeSignals(List<RiskSignal> target, List<RiskSignal> overlay)
|
||||
{
|
||||
var signalsByName = target.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var signal in overlay)
|
||||
{
|
||||
if (signalsByName.TryGetValue(signal.Name, out var existing))
|
||||
{
|
||||
// Replace existing signal
|
||||
var index = target.IndexOf(existing);
|
||||
target[index] = CloneSignal(signal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new signal
|
||||
target.Add(CloneSignal(signal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeOverrides(RiskOverrides target, RiskOverrides overlay)
|
||||
{
|
||||
// Append severity overrides (overlay rules take precedence by being evaluated later)
|
||||
foreach (var rule in overlay.Severity)
|
||||
{
|
||||
target.Severity.Add(CloneSeverityOverride(rule));
|
||||
}
|
||||
|
||||
// Append decision overrides
|
||||
foreach (var rule in overlay.Decisions)
|
||||
{
|
||||
target.Decisions.Add(CloneDecisionOverride(rule));
|
||||
}
|
||||
}
|
||||
|
||||
private static RiskProfileModel CloneProfile(RiskProfileModel source)
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = source.Id,
|
||||
Version = source.Version,
|
||||
Description = source.Description,
|
||||
Extends = source.Extends,
|
||||
Signals = source.Signals.Select(CloneSignal).ToList(),
|
||||
Weights = new Dictionary<string, double>(source.Weights),
|
||||
Overrides = new RiskOverrides
|
||||
{
|
||||
Severity = source.Overrides.Severity.Select(CloneSeverityOverride).ToList(),
|
||||
Decisions = source.Overrides.Decisions.Select(CloneDecisionOverride).ToList(),
|
||||
},
|
||||
Metadata = source.Metadata != null
|
||||
? new Dictionary<string, object?>(source.Metadata)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSignal CloneSignal(RiskSignal source)
|
||||
{
|
||||
return new RiskSignal
|
||||
{
|
||||
Name = source.Name,
|
||||
Source = source.Source,
|
||||
Type = source.Type,
|
||||
Path = source.Path,
|
||||
Transform = source.Transform,
|
||||
Unit = source.Unit,
|
||||
};
|
||||
}
|
||||
|
||||
private static SeverityOverride CloneSeverityOverride(SeverityOverride source)
|
||||
{
|
||||
return new SeverityOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(source.When),
|
||||
Set = source.Set,
|
||||
};
|
||||
}
|
||||
|
||||
private static DecisionOverride CloneDecisionOverride(DecisionOverride source)
|
||||
{
|
||||
return new DecisionOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(source.When),
|
||||
Action = source.Action,
|
||||
Reason = source.Reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user