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

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

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