Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
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
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
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
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Schema;
|
||||
using StellaOps.Policy.RiskProfile.Validation;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostics report for a risk profile validation.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileDiagnosticsReport(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
int SignalCount,
|
||||
int WeightCount,
|
||||
int OverrideCount,
|
||||
int ErrorCount,
|
||||
int WarningCount,
|
||||
DateTimeOffset GeneratedAt,
|
||||
ImmutableArray<RiskProfileIssue> Issues,
|
||||
ImmutableArray<string> Recommendations);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation issue in a risk profile.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileIssue(
|
||||
string Code,
|
||||
string Message,
|
||||
RiskProfileIssueSeverity Severity,
|
||||
string Path)
|
||||
{
|
||||
public static RiskProfileIssue Error(string code, string message, string path)
|
||||
=> new(code, message, RiskProfileIssueSeverity.Error, path);
|
||||
|
||||
public static RiskProfileIssue Warning(string code, string message, string path)
|
||||
=> new(code, message, RiskProfileIssueSeverity.Warning, path);
|
||||
|
||||
public static RiskProfileIssue Info(string code, string message, string path)
|
||||
=> new(code, message, RiskProfileIssueSeverity.Info, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for risk profile issues.
|
||||
/// </summary>
|
||||
public enum RiskProfileIssueSeverity
|
||||
{
|
||||
Error,
|
||||
Warning,
|
||||
Info
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides validation and diagnostics for risk profiles.
|
||||
/// </summary>
|
||||
public static class RiskProfileDiagnostics
|
||||
{
|
||||
private static readonly RiskProfileValidator Validator = new();
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a diagnostics report for a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to validate.</param>
|
||||
/// <param name="timeProvider">Optional time provider.</param>
|
||||
/// <returns>Diagnostics report.</returns>
|
||||
public static RiskProfileDiagnosticsReport Create(RiskProfileModel profile, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
|
||||
|
||||
ValidateStructure(profile, issues);
|
||||
ValidateSignals(profile, issues);
|
||||
ValidateWeights(profile, issues);
|
||||
ValidateOverrides(profile, issues);
|
||||
ValidateInheritance(profile, issues);
|
||||
|
||||
var errorCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Error);
|
||||
var warningCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Warning);
|
||||
var recommendations = BuildRecommendations(profile, errorCount, warningCount);
|
||||
var overrideCount = profile.Overrides.Severity.Count + profile.Overrides.Decisions.Count;
|
||||
|
||||
return new RiskProfileDiagnosticsReport(
|
||||
profile.Id,
|
||||
profile.Version,
|
||||
profile.Signals.Count,
|
||||
profile.Weights.Count,
|
||||
overrideCount,
|
||||
errorCount,
|
||||
warningCount,
|
||||
time,
|
||||
issues.ToImmutable(),
|
||||
recommendations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a risk profile JSON against the schema.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON to validate.</param>
|
||||
/// <returns>Collection of validation issues.</returns>
|
||||
public static ImmutableArray<RiskProfileIssue> ValidateJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ImmutableArray.Create(
|
||||
RiskProfileIssue.Error("RISK001", "Profile JSON is required.", "/"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = Validator.Validate(json);
|
||||
if (results.IsValid)
|
||||
{
|
||||
return ImmutableArray<RiskProfileIssue>.Empty;
|
||||
}
|
||||
|
||||
return ExtractSchemaErrors(results).ToImmutableArray();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ImmutableArray.Create(
|
||||
RiskProfileIssue.Error("RISK002", $"Invalid JSON: {ex.Message}", "/"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a risk profile model for semantic correctness.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to validate.</param>
|
||||
/// <returns>Collection of validation issues.</returns>
|
||||
public static ImmutableArray<RiskProfileIssue> Validate(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
|
||||
|
||||
ValidateStructure(profile, issues);
|
||||
ValidateSignals(profile, issues);
|
||||
ValidateWeights(profile, issues);
|
||||
ValidateOverrides(profile, issues);
|
||||
ValidateInheritance(profile, issues);
|
||||
|
||||
return issues.ToImmutable();
|
||||
}
|
||||
|
||||
private static void ValidateStructure(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile.Id))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK010", "Profile ID is required.", "/id"));
|
||||
}
|
||||
else if (profile.Id.Contains(' '))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK011", "Profile ID should not contain spaces.", "/id"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.Version))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK012", "Profile version is required.", "/version"));
|
||||
}
|
||||
else if (!IsValidSemVer(profile.Version))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK013", "Profile version should follow SemVer format.", "/version"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSignals(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
if (profile.Signals.Count == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK020", "Profile has no signals defined.", "/signals"));
|
||||
return;
|
||||
}
|
||||
|
||||
var signalNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (int i = 0; i < profile.Signals.Count; i++)
|
||||
{
|
||||
var signal = profile.Signals[i];
|
||||
var path = $"/signals/{i}";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signal.Name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK021", $"Signal at index {i} has no name.", path));
|
||||
}
|
||||
else if (!signalNames.Add(signal.Name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK022", $"Duplicate signal name: {signal.Name}", path));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signal.Source))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK023", $"Signal '{signal.Name}' has no source.", $"{path}/source"));
|
||||
}
|
||||
|
||||
if (signal.Type == RiskSignalType.Numeric && string.IsNullOrWhiteSpace(signal.Unit))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Info("RISK024", $"Numeric signal '{signal.Name}' has no unit specified.", $"{path}/unit"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWeights(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
var signalNames = profile.Signals.Select(s => s.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
double totalWeight = 0;
|
||||
|
||||
foreach (var (name, weight) in profile.Weights)
|
||||
{
|
||||
if (!signalNames.Contains(name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK030", $"Weight defined for unknown signal: {name}", $"/weights/{name}"));
|
||||
}
|
||||
|
||||
if (weight < 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK031", $"Weight for '{name}' is negative: {weight}", $"/weights/{name}"));
|
||||
}
|
||||
else if (weight == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Info("RISK032", $"Weight for '{name}' is zero.", $"/weights/{name}"));
|
||||
}
|
||||
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
foreach (var signal in profile.Signals)
|
||||
{
|
||||
if (!profile.Weights.ContainsKey(signal.Name))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK033", $"Signal '{signal.Name}' has no weight defined.", $"/weights"));
|
||||
}
|
||||
}
|
||||
|
||||
if (totalWeight > 0 && Math.Abs(totalWeight - 1.0) > 0.01)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Info("RISK034", $"Weights sum to {totalWeight:F3}; consider normalizing to 1.0.", "/weights"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateOverrides(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
for (int i = 0; i < profile.Overrides.Severity.Count; i++)
|
||||
{
|
||||
var rule = profile.Overrides.Severity[i];
|
||||
var path = $"/overrides/severity/{i}";
|
||||
|
||||
if (rule.When.Count == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK040", $"Severity override at index {i} has empty 'when' clause.", path));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < profile.Overrides.Decisions.Count; i++)
|
||||
{
|
||||
var rule = profile.Overrides.Decisions[i];
|
||||
var path = $"/overrides/decisions/{i}";
|
||||
|
||||
if (rule.When.Count == 0)
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK041", $"Decision override at index {i} has empty 'when' clause.", path));
|
||||
}
|
||||
|
||||
if (rule.Action == RiskAction.Deny && string.IsNullOrWhiteSpace(rule.Reason))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Warning("RISK042", $"Decision override at index {i} with 'deny' action should have a reason.", path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateInheritance(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(profile.Extends))
|
||||
{
|
||||
if (string.Equals(profile.Extends, profile.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(RiskProfileIssue.Error("RISK050", "Profile cannot extend itself.", "/extends"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildRecommendations(RiskProfileModel profile, int errorCount, int warningCount)
|
||||
{
|
||||
var recommendations = ImmutableArray.CreateBuilder<string>();
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
recommendations.Add("Resolve errors before using this profile in production.");
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
recommendations.Add("Review warnings to ensure profile behaves as expected.");
|
||||
}
|
||||
|
||||
if (profile.Signals.Count == 0)
|
||||
{
|
||||
recommendations.Add("Add at least one signal to enable risk scoring.");
|
||||
}
|
||||
|
||||
if (profile.Weights.Count == 0 && profile.Signals.Count > 0)
|
||||
{
|
||||
recommendations.Add("Define weights for signals to control scoring influence.");
|
||||
}
|
||||
|
||||
var hasReachability = profile.Signals.Any(s =>
|
||||
s.Name.Equals("reachability", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasReachability)
|
||||
{
|
||||
recommendations.Add("Consider adding a reachability signal to prioritize exploitable vulnerabilities.");
|
||||
}
|
||||
|
||||
if (recommendations.Count == 0)
|
||||
{
|
||||
recommendations.Add("Risk profile validated successfully; ready for use.");
|
||||
}
|
||||
|
||||
return recommendations.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<RiskProfileIssue> ExtractSchemaErrors(ValidationResults results)
|
||||
{
|
||||
if (results.Details != null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
{
|
||||
if (detail.HasErrors)
|
||||
{
|
||||
foreach (var error in detail.Errors ?? [])
|
||||
{
|
||||
yield return RiskProfileIssue.Error(
|
||||
"RISK003",
|
||||
error.Value ?? "Schema validation failed",
|
||||
detail.EvaluationPath?.ToString() ?? "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(results.Message))
|
||||
{
|
||||
yield return RiskProfileIssue.Error("RISK003", results.Message, "/");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSemVer(string version)
|
||||
{
|
||||
var parts = version.Split(['-', '+'], 2);
|
||||
return Version.TryParse(parts[0], out _);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,15 @@
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for persisting and retrieving risk profiles.
|
||||
/// </summary>
|
||||
public interface IRiskProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a risk profile by ID.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The profile, or null if not found.</returns>
|
||||
Task<RiskProfileModel?> GetAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific version of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The semantic version.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The profile version, or null if not found.</returns>
|
||||
Task<RiskProfileModel?> GetVersionAsync(string profileId, string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest version of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The latest profile version, or null if not found.</returns>
|
||||
Task<RiskProfileModel?> GetLatestAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available risk profile IDs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of profile IDs.</returns>
|
||||
Task<IReadOnlyList<string>> ListProfileIdsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all versions of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of profile versions ordered by version descending.</returns>
|
||||
Task<IReadOnlyList<RiskProfileModel>> ListVersionsAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to save.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if saved successfully, false if version conflict.</returns>
|
||||
Task<bool> SaveAsync(RiskProfileModel profile, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific version of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteVersionAsync(string profileId, string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all versions of a risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAllVersionsAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a profile exists.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the profile exists.</returns>
|
||||
Task<bool> ExistsAsync(string profileId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of risk profile repository for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskProfileRepository : IRiskProfileRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, RiskProfileModel>> _profiles = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<RiskProfileModel?> GetAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetLatestAsync(profileId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RiskProfileModel?> GetVersionAsync(string profileId, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_profiles.TryGetValue(profileId, out var versions) &&
|
||||
versions.TryGetValue(version, out var profile))
|
||||
{
|
||||
return Task.FromResult<RiskProfileModel?>(CloneProfile(profile));
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskProfileModel?>(null);
|
||||
}
|
||||
|
||||
public Task<RiskProfileModel?> GetLatestAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_profiles.TryGetValue(profileId, out var versions) || versions.IsEmpty)
|
||||
{
|
||||
return Task.FromResult<RiskProfileModel?>(null);
|
||||
}
|
||||
|
||||
var latest = versions.Values
|
||||
.OrderByDescending(p => ParseVersion(p.Version))
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(latest != null ? CloneProfile(latest) : null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> ListProfileIdsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = _profiles.Keys.ToList().AsReadOnly();
|
||||
return Task.FromResult<IReadOnlyList<string>>(ids);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskProfileModel>> ListVersionsAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_profiles.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<RiskProfileModel>>(Array.Empty<RiskProfileModel>());
|
||||
}
|
||||
|
||||
var list = versions.Values
|
||||
.OrderByDescending(p => ParseVersion(p.Version))
|
||||
.Select(CloneProfile)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskProfileModel>>(list);
|
||||
}
|
||||
|
||||
public Task<bool> SaveAsync(RiskProfileModel profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var versions = _profiles.GetOrAdd(profile.Id, _ => new ConcurrentDictionary<string, RiskProfileModel>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (versions.ContainsKey(profile.Version))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
versions[profile.Version] = CloneProfile(profile);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteVersionAsync(string profileId, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_profiles.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
var removed = versions.TryRemove(version, out _);
|
||||
|
||||
if (versions.IsEmpty)
|
||||
{
|
||||
_profiles.TryRemove(profileId, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAllVersionsAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var removed = _profiles.TryRemove(profileId, out _);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var exists = _profiles.TryGetValue(profileId, out var versions) && !versions.IsEmpty;
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string version)
|
||||
{
|
||||
if (Version.TryParse(version, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var parts = version.Split(['-', '+'], 2);
|
||||
if (parts.Length > 0 && Version.TryParse(parts[0], out parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
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(s => new RiskSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = s.Type,
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit
|
||||
}).ToList(),
|
||||
Weights = new Dictionary<string, double>(source.Weights),
|
||||
Overrides = new RiskOverrides
|
||||
{
|
||||
Severity = source.Overrides.Severity.Select(r => new SeverityOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(r.When),
|
||||
Set = r.Set
|
||||
}).ToList(),
|
||||
Decisions = source.Overrides.Decisions.Select(r => new DecisionOverride
|
||||
{
|
||||
When = new Dictionary<string, object>(r.When),
|
||||
Action = r.Action,
|
||||
Reason = r.Reason
|
||||
}).ToList()
|
||||
},
|
||||
Metadata = source.Metadata != null
|
||||
? new Dictionary<string, object?>(source.Metadata)
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user