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,213 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing deterministic hashes of risk profiles.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileHasher
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic SHA-256 hash of the risk profile.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to hash.</param>
|
||||
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeHash(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var canonical = CreateCanonicalForm(profile);
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic content hash that ignores identity fields (id, version).
|
||||
/// Useful for detecting semantic changes regardless of versioning.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to hash.</param>
|
||||
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeContentHash(RiskProfileModel profile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var canonical = CreateCanonicalContentForm(profile);
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two profiles have the same semantic content (ignoring identity fields).
|
||||
/// </summary>
|
||||
public bool AreEquivalent(RiskProfileModel profile1, RiskProfileModel profile2)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile1);
|
||||
ArgumentNullException.ThrowIfNull(profile2);
|
||||
|
||||
return ComputeContentHash(profile1) == ComputeContentHash(profile2);
|
||||
}
|
||||
|
||||
private static CanonicalRiskProfile CreateCanonicalForm(RiskProfileModel profile)
|
||||
{
|
||||
return new CanonicalRiskProfile
|
||||
{
|
||||
Id = profile.Id,
|
||||
Version = profile.Version,
|
||||
Description = profile.Description,
|
||||
Extends = profile.Extends,
|
||||
Signals = CreateCanonicalSignals(profile.Signals),
|
||||
Weights = CreateCanonicalWeights(profile.Weights),
|
||||
Overrides = CreateCanonicalOverrides(profile.Overrides),
|
||||
Metadata = CreateCanonicalMetadata(profile.Metadata),
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalRiskProfileContent CreateCanonicalContentForm(RiskProfileModel profile)
|
||||
{
|
||||
return new CanonicalRiskProfileContent
|
||||
{
|
||||
Signals = CreateCanonicalSignals(profile.Signals),
|
||||
Weights = CreateCanonicalWeights(profile.Weights),
|
||||
Overrides = CreateCanonicalOverrides(profile.Overrides),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CanonicalSignal> CreateCanonicalSignals(List<RiskSignal> signals)
|
||||
{
|
||||
return signals
|
||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Select(s => new CanonicalSignal
|
||||
{
|
||||
Name = s.Name,
|
||||
Source = s.Source,
|
||||
Type = s.Type.ToString().ToLowerInvariant(),
|
||||
Path = s.Path,
|
||||
Transform = s.Transform,
|
||||
Unit = s.Unit,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, double> CreateCanonicalWeights(Dictionary<string, double> weights)
|
||||
{
|
||||
return new SortedDictionary<string, double>(weights, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static CanonicalOverrides CreateCanonicalOverrides(RiskOverrides overrides)
|
||||
{
|
||||
return new CanonicalOverrides
|
||||
{
|
||||
Severity = overrides.Severity
|
||||
.Select(CreateCanonicalSeverityOverride)
|
||||
.ToList(),
|
||||
Decisions = overrides.Decisions
|
||||
.Select(CreateCanonicalDecisionOverride)
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalSeverityOverride CreateCanonicalSeverityOverride(SeverityOverride rule)
|
||||
{
|
||||
return new CanonicalSeverityOverride
|
||||
{
|
||||
When = CreateCanonicalWhen(rule.When),
|
||||
Set = rule.Set.ToString().ToLowerInvariant(),
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalDecisionOverride CreateCanonicalDecisionOverride(DecisionOverride rule)
|
||||
{
|
||||
return new CanonicalDecisionOverride
|
||||
{
|
||||
When = CreateCanonicalWhen(rule.When),
|
||||
Action = rule.Action.ToString().ToLowerInvariant(),
|
||||
Reason = rule.Reason,
|
||||
};
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object> CreateCanonicalWhen(Dictionary<string, object> when)
|
||||
{
|
||||
return new SortedDictionary<string, object>(when, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object?>? CreateCanonicalMetadata(Dictionary<string, object?>? metadata)
|
||||
{
|
||||
if (metadata == null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SortedDictionary<string, object?>(metadata, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
#region Canonical Form Types
|
||||
|
||||
private sealed class CanonicalRiskProfile
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Extends { get; init; }
|
||||
public required List<CanonicalSignal> Signals { get; init; }
|
||||
public required SortedDictionary<string, double> Weights { get; init; }
|
||||
public required CanonicalOverrides Overrides { get; init; }
|
||||
public SortedDictionary<string, object?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalRiskProfileContent
|
||||
{
|
||||
public required List<CanonicalSignal> Signals { get; init; }
|
||||
public required SortedDictionary<string, double> Weights { get; init; }
|
||||
public required CanonicalOverrides Overrides { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalSignal
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public string? Transform { get; init; }
|
||||
public string? Unit { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalOverrides
|
||||
{
|
||||
public required List<CanonicalSeverityOverride> Severity { get; init; }
|
||||
public required List<CanonicalDecisionOverride> Decisions { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalSeverityOverride
|
||||
{
|
||||
public required SortedDictionary<string, object> When { get; init; }
|
||||
public required string Set { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CanonicalDecisionOverride
|
||||
{
|
||||
public required SortedDictionary<string, object> When { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status of a risk profile.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleStatus>))]
|
||||
public enum RiskProfileLifecycleStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile is in draft/development.
|
||||
/// </summary>
|
||||
[JsonPropertyName("draft")]
|
||||
Draft,
|
||||
|
||||
/// <summary>
|
||||
/// Profile is active and available for use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("active")]
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Profile is deprecated; use is discouraged.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecated")]
|
||||
Deprecated,
|
||||
|
||||
/// <summary>
|
||||
/// Profile is archived; no longer available for new use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("archived")]
|
||||
Archived
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a profile version.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileVersionInfo(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("status")] RiskProfileLifecycleStatus Status,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("activated_at")] DateTimeOffset? ActivatedAt,
|
||||
[property: JsonPropertyName("deprecated_at")] DateTimeOffset? DeprecatedAt,
|
||||
[property: JsonPropertyName("archived_at")] DateTimeOffset? ArchivedAt,
|
||||
[property: JsonPropertyName("content_hash")] string ContentHash,
|
||||
[property: JsonPropertyName("successor_version")] string? SuccessorVersion = null,
|
||||
[property: JsonPropertyName("deprecation_reason")] string? DeprecationReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a profile lifecycle changes.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileLifecycleEvent(
|
||||
[property: JsonPropertyName("event_id")] string EventId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("event_type")] RiskProfileLifecycleEventType EventType,
|
||||
[property: JsonPropertyName("old_status")] RiskProfileLifecycleStatus? OldStatus,
|
||||
[property: JsonPropertyName("new_status")] RiskProfileLifecycleStatus NewStatus,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("actor")] string? Actor,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Types of lifecycle events.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleEventType>))]
|
||||
public enum RiskProfileLifecycleEventType
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
Created,
|
||||
|
||||
[JsonPropertyName("activated")]
|
||||
Activated,
|
||||
|
||||
[JsonPropertyName("deprecated")]
|
||||
Deprecated,
|
||||
|
||||
[JsonPropertyName("archived")]
|
||||
Archived,
|
||||
|
||||
[JsonPropertyName("restored")]
|
||||
Restored
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a version comparison.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileVersionComparison(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("from_version")] string FromVersion,
|
||||
[property: JsonPropertyName("to_version")] string ToVersion,
|
||||
[property: JsonPropertyName("has_breaking_changes")] bool HasBreakingChanges,
|
||||
[property: JsonPropertyName("changes")] IReadOnlyList<RiskProfileChange> Changes);
|
||||
|
||||
/// <summary>
|
||||
/// A specific change between profile versions.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileChange(
|
||||
[property: JsonPropertyName("change_type")] RiskProfileChangeType ChangeType,
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("is_breaking")] bool IsBreaking);
|
||||
|
||||
/// <summary>
|
||||
/// Types of changes between profile versions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileChangeType>))]
|
||||
public enum RiskProfileChangeType
|
||||
{
|
||||
[JsonPropertyName("signal_added")]
|
||||
SignalAdded,
|
||||
|
||||
[JsonPropertyName("signal_removed")]
|
||||
SignalRemoved,
|
||||
|
||||
[JsonPropertyName("signal_modified")]
|
||||
SignalModified,
|
||||
|
||||
[JsonPropertyName("weight_changed")]
|
||||
WeightChanged,
|
||||
|
||||
[JsonPropertyName("override_added")]
|
||||
OverrideAdded,
|
||||
|
||||
[JsonPropertyName("override_removed")]
|
||||
OverrideRemoved,
|
||||
|
||||
[JsonPropertyName("override_modified")]
|
||||
OverrideModified,
|
||||
|
||||
[JsonPropertyName("metadata_changed")]
|
||||
MetadataChanged,
|
||||
|
||||
[JsonPropertyName("inheritance_changed")]
|
||||
InheritanceChanged
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing risk profile lifecycle and versioning.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileLifecycleService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ConcurrentDictionary<string, List<RiskProfileVersionInfo>> _versions;
|
||||
private readonly ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>> _events;
|
||||
|
||||
public RiskProfileLifecycleService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hasher = new RiskProfileHasher();
|
||||
_versions = new ConcurrentDictionary<string, List<RiskProfileVersionInfo>>(StringComparer.OrdinalIgnoreCase);
|
||||
_events = new ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new profile version in draft status.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to create.</param>
|
||||
/// <param name="createdBy">Creator identifier.</param>
|
||||
/// <returns>Version info for the created profile.</returns>
|
||||
public RiskProfileVersionInfo CreateVersion(RiskProfileModel profile, string? createdBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var contentHash = _hasher.ComputeContentHash(profile);
|
||||
|
||||
var versionInfo = new RiskProfileVersionInfo(
|
||||
ProfileId: profile.Id,
|
||||
Version: profile.Version,
|
||||
Status: RiskProfileLifecycleStatus.Draft,
|
||||
CreatedAt: now,
|
||||
CreatedBy: createdBy,
|
||||
ActivatedAt: null,
|
||||
DeprecatedAt: null,
|
||||
ArchivedAt: null,
|
||||
ContentHash: contentHash);
|
||||
|
||||
var versions = _versions.GetOrAdd(profile.Id, _ => new List<RiskProfileVersionInfo>());
|
||||
lock (versions)
|
||||
{
|
||||
if (versions.Any(v => v.Version == profile.Version))
|
||||
{
|
||||
throw new InvalidOperationException($"Version {profile.Version} already exists for profile {profile.Id}.");
|
||||
}
|
||||
versions.Add(versionInfo);
|
||||
}
|
||||
|
||||
RecordEvent(profile.Id, profile.Version, RiskProfileLifecycleEventType.Created, null, RiskProfileLifecycleStatus.Draft, createdBy);
|
||||
|
||||
return versionInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates a profile version, making it available for use.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to activate.</param>
|
||||
/// <param name="actor">Actor performing the activation.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Activate(string profileId, string version, string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status != RiskProfileLifecycleStatus.Draft)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot activate profile in {info.Status} status. Only Draft profiles can be activated.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Active,
|
||||
ActivatedAt = now
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Activated, info.Status, RiskProfileLifecycleStatus.Active, actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deprecates a profile version.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to deprecate.</param>
|
||||
/// <param name="successorVersion">Optional successor version to recommend.</param>
|
||||
/// <param name="reason">Reason for deprecation.</param>
|
||||
/// <param name="actor">Actor performing the deprecation.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Deprecate(
|
||||
string profileId,
|
||||
string version,
|
||||
string? successorVersion = null,
|
||||
string? reason = null,
|
||||
string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status != RiskProfileLifecycleStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot deprecate profile in {info.Status} status. Only Active profiles can be deprecated.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Deprecated,
|
||||
DeprecatedAt = now,
|
||||
SuccessorVersion = successorVersion,
|
||||
DeprecationReason = reason
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Deprecated, info.Status, RiskProfileLifecycleStatus.Deprecated, actor, reason);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Archives a profile version, removing it from active use.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to archive.</param>
|
||||
/// <param name="actor">Actor performing the archive.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Archive(string profileId, string version, string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status == RiskProfileLifecycleStatus.Archived)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Archived,
|
||||
ArchivedAt = now
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Archived, info.Status, RiskProfileLifecycleStatus.Archived, actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores an archived profile to deprecated status.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile ID.</param>
|
||||
/// <param name="version">The version to restore.</param>
|
||||
/// <param name="actor">Actor performing the restoration.</param>
|
||||
/// <returns>Updated version info.</returns>
|
||||
public RiskProfileVersionInfo Restore(string profileId, string version, string? actor = null)
|
||||
{
|
||||
var info = GetVersionInfo(profileId, version);
|
||||
if (info == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
|
||||
}
|
||||
|
||||
if (info.Status != RiskProfileLifecycleStatus.Archived)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot restore profile in {info.Status} status. Only Archived profiles can be restored.");
|
||||
}
|
||||
|
||||
var updated = info with
|
||||
{
|
||||
Status = RiskProfileLifecycleStatus.Deprecated,
|
||||
ArchivedAt = null
|
||||
};
|
||||
|
||||
UpdateVersionInfo(profileId, version, updated);
|
||||
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Restored, info.Status, RiskProfileLifecycleStatus.Deprecated, actor);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets version info for a specific profile version.
|
||||
/// </summary>
|
||||
public RiskProfileVersionInfo? GetVersionInfo(string profileId, string version)
|
||||
{
|
||||
if (_versions.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
lock (versions)
|
||||
{
|
||||
return versions.FirstOrDefault(v => v.Version == version);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all versions for a profile.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RiskProfileVersionInfo> GetAllVersions(string profileId)
|
||||
{
|
||||
if (_versions.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
lock (versions)
|
||||
{
|
||||
return versions.OrderByDescending(v => ParseVersion(v.Version)).ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<RiskProfileVersionInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest active version for a profile.
|
||||
/// </summary>
|
||||
public RiskProfileVersionInfo? GetLatestActive(string profileId)
|
||||
{
|
||||
var versions = GetAllVersions(profileId);
|
||||
return versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lifecycle events for a profile.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RiskProfileLifecycleEvent> GetEvents(string profileId, int limit = 100)
|
||||
{
|
||||
if (_events.TryGetValue(profileId, out var events))
|
||||
{
|
||||
lock (events)
|
||||
{
|
||||
return events.OrderByDescending(e => e.Timestamp).Take(limit).ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<RiskProfileLifecycleEvent>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two profile versions and returns the differences.
|
||||
/// </summary>
|
||||
public RiskProfileVersionComparison CompareVersions(
|
||||
RiskProfileModel fromProfile,
|
||||
RiskProfileModel toProfile)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromProfile);
|
||||
ArgumentNullException.ThrowIfNull(toProfile);
|
||||
|
||||
if (fromProfile.Id != toProfile.Id)
|
||||
{
|
||||
throw new ArgumentException("Profiles must have the same ID to compare.");
|
||||
}
|
||||
|
||||
var changes = new List<RiskProfileChange>();
|
||||
var hasBreaking = false;
|
||||
|
||||
CompareSignals(fromProfile, toProfile, changes, ref hasBreaking);
|
||||
CompareWeights(fromProfile, toProfile, changes);
|
||||
CompareOverrides(fromProfile, toProfile, changes);
|
||||
CompareInheritance(fromProfile, toProfile, changes, ref hasBreaking);
|
||||
CompareMetadata(fromProfile, toProfile, changes);
|
||||
|
||||
return new RiskProfileVersionComparison(
|
||||
ProfileId: fromProfile.Id,
|
||||
FromVersion: fromProfile.Version,
|
||||
ToVersion: toProfile.Version,
|
||||
HasBreakingChanges: hasBreaking,
|
||||
Changes: changes.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if an upgrade from one version to another is safe (non-breaking).
|
||||
/// </summary>
|
||||
public bool IsSafeUpgrade(RiskProfileModel fromProfile, RiskProfileModel toProfile)
|
||||
{
|
||||
var comparison = CompareVersions(fromProfile, toProfile);
|
||||
return !comparison.HasBreakingChanges;
|
||||
}
|
||||
|
||||
private void UpdateVersionInfo(string profileId, string version, RiskProfileVersionInfo updated)
|
||||
{
|
||||
if (_versions.TryGetValue(profileId, out var versions))
|
||||
{
|
||||
lock (versions)
|
||||
{
|
||||
var index = versions.FindIndex(v => v.Version == version);
|
||||
if (index >= 0)
|
||||
{
|
||||
versions[index] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordEvent(
|
||||
string profileId,
|
||||
string version,
|
||||
RiskProfileLifecycleEventType eventType,
|
||||
RiskProfileLifecycleStatus? oldStatus,
|
||||
RiskProfileLifecycleStatus newStatus,
|
||||
string? actor,
|
||||
string? reason = null)
|
||||
{
|
||||
var eventId = GenerateEventId();
|
||||
var evt = new RiskProfileLifecycleEvent(
|
||||
EventId: eventId,
|
||||
ProfileId: profileId,
|
||||
Version: version,
|
||||
EventType: eventType,
|
||||
OldStatus: oldStatus,
|
||||
NewStatus: newStatus,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
Reason: reason);
|
||||
|
||||
var events = _events.GetOrAdd(profileId, _ => new List<RiskProfileLifecycleEvent>());
|
||||
lock (events)
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareSignals(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes,
|
||||
ref bool hasBreaking)
|
||||
{
|
||||
var fromSignals = from.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
var toSignals = to.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (name, signal) in fromSignals)
|
||||
{
|
||||
if (!toSignals.ContainsKey(name))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.SignalRemoved,
|
||||
$"/signals/{name}",
|
||||
$"Signal '{name}' was removed",
|
||||
IsBreaking: true));
|
||||
hasBreaking = true;
|
||||
}
|
||||
else if (!SignalsEqual(signal, toSignals[name]))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.SignalModified,
|
||||
$"/signals/{name}",
|
||||
$"Signal '{name}' was modified",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var name in toSignals.Keys)
|
||||
{
|
||||
if (!fromSignals.ContainsKey(name))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.SignalAdded,
|
||||
$"/signals/{name}",
|
||||
$"Signal '{name}' was added",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareWeights(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes)
|
||||
{
|
||||
var allKeys = from.Weights.Keys.Union(to.Weights.Keys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var key in allKeys)
|
||||
{
|
||||
var fromHas = from.Weights.TryGetValue(key, out var fromWeight);
|
||||
var toHas = to.Weights.TryGetValue(key, out var toWeight);
|
||||
|
||||
if (fromHas && toHas && Math.Abs(fromWeight - toWeight) > 0.001)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.WeightChanged,
|
||||
$"/weights/{key}",
|
||||
$"Weight for '{key}' changed from {fromWeight:F3} to {toWeight:F3}",
|
||||
IsBreaking: false));
|
||||
}
|
||||
else if (fromHas && !toHas)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.WeightChanged,
|
||||
$"/weights/{key}",
|
||||
$"Weight for '{key}' was removed",
|
||||
IsBreaking: false));
|
||||
}
|
||||
else if (!fromHas && toHas)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.WeightChanged,
|
||||
$"/weights/{key}",
|
||||
$"Weight for '{key}' was added with value {toWeight:F3}",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareOverrides(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes)
|
||||
{
|
||||
if (from.Overrides.Severity.Count != to.Overrides.Severity.Count)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.OverrideModified,
|
||||
"/overrides/severity",
|
||||
$"Severity overrides changed from {from.Overrides.Severity.Count} to {to.Overrides.Severity.Count} rules",
|
||||
IsBreaking: false));
|
||||
}
|
||||
|
||||
if (from.Overrides.Decisions.Count != to.Overrides.Decisions.Count)
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.OverrideModified,
|
||||
"/overrides/decisions",
|
||||
$"Decision overrides changed from {from.Overrides.Decisions.Count} to {to.Overrides.Decisions.Count} rules",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareInheritance(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes,
|
||||
ref bool hasBreaking)
|
||||
{
|
||||
if (!string.Equals(from.Extends, to.Extends, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fromExtends = from.Extends ?? "(none)";
|
||||
var toExtends = to.Extends ?? "(none)";
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.InheritanceChanged,
|
||||
"/extends",
|
||||
$"Inheritance changed from '{fromExtends}' to '{toExtends}'",
|
||||
IsBreaking: true));
|
||||
hasBreaking = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareMetadata(
|
||||
RiskProfileModel from,
|
||||
RiskProfileModel to,
|
||||
List<RiskProfileChange> changes)
|
||||
{
|
||||
var fromKeys = from.Metadata?.Keys ?? Enumerable.Empty<string>();
|
||||
var toKeys = to.Metadata?.Keys ?? Enumerable.Empty<string>();
|
||||
var allKeys = fromKeys.Union(toKeys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var key in allKeys)
|
||||
{
|
||||
var fromHas = from.Metadata?.TryGetValue(key, out var fromValue) ?? false;
|
||||
var toHas = to.Metadata?.TryGetValue(key, out var toValue) ?? false;
|
||||
|
||||
if (fromHas != toHas || (fromHas && toHas && !Equals(fromValue, toValue)))
|
||||
{
|
||||
changes.Add(new RiskProfileChange(
|
||||
RiskProfileChangeType.MetadataChanged,
|
||||
$"/metadata/{key}",
|
||||
$"Metadata key '{key}' was changed",
|
||||
IsBreaking: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SignalsEqual(RiskSignal a, RiskSignal b)
|
||||
{
|
||||
return a.Source == b.Source &&
|
||||
a.Type == b.Type &&
|
||||
a.Path == b.Path &&
|
||||
a.Transform == b.Transform &&
|
||||
a.Unit == b.Unit;
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string version)
|
||||
{
|
||||
var parts = version.Split(['-', '+'], 2);
|
||||
if (Version.TryParse(parts[0], out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static string GenerateEventId()
|
||||
{
|
||||
var guid = Guid.NewGuid().ToByteArray();
|
||||
return $"rple-{Convert.ToHexStringLower(guid)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a risk profile definition used to score and prioritize findings.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable identifier for the risk profile (slug or URN).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SemVer for the profile definition.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of the profile intent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional parent profile ID for inheritance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("extends")]
|
||||
public string? Extends { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal definitions used for risk scoring.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signals")]
|
||||
public List<RiskSignal> Signals { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Weight per signal name; weights are normalized by the consumer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weights")]
|
||||
public Dictionary<string, double> Weights { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Override rules for severity and decisions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overrides")]
|
||||
public RiskOverrides Overrides { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Free-form metadata with stable keys.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signal definition used in risk scoring.
|
||||
/// </summary>
|
||||
public sealed class RiskSignal
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical signal key (e.g., reachability, kev, exploit_chain).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upstream provider or calculation origin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required RiskSignalType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON Pointer to the signal in the evidence document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional transform applied before weighting.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transform")]
|
||||
public string? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional unit for numeric signals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unit")]
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal type enumeration.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskSignalType>))]
|
||||
public enum RiskSignalType
|
||||
{
|
||||
[JsonPropertyName("boolean")]
|
||||
Boolean,
|
||||
|
||||
[JsonPropertyName("numeric")]
|
||||
Numeric,
|
||||
|
||||
[JsonPropertyName("categorical")]
|
||||
Categorical,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override rules for severity and decisions.
|
||||
/// </summary>
|
||||
public sealed class RiskOverrides
|
||||
{
|
||||
/// <summary>
|
||||
/// Severity override rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public List<SeverityOverride> Severity { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Decision override rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decisions")]
|
||||
public List<DecisionOverride> Decisions { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A severity override rule.
|
||||
/// </summary>
|
||||
public sealed class SeverityOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate over signals (key/value equals).
|
||||
/// </summary>
|
||||
[JsonPropertyName("when")]
|
||||
public required Dictionary<string, object> When { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity to set when predicate matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("set")]
|
||||
public required RiskSeverity Set { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A decision override rule.
|
||||
/// </summary>
|
||||
public sealed class DecisionOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate over signals (key/value equals).
|
||||
/// </summary>
|
||||
[JsonPropertyName("when")]
|
||||
public required Dictionary<string, object> When { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when predicate matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required RiskAction Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the override.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskSeverity>))]
|
||||
public enum RiskSeverity
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
Critical,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
Medium,
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("informational")]
|
||||
Informational,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision actions.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskAction>))]
|
||||
public enum RiskAction
|
||||
{
|
||||
[JsonPropertyName("allow")]
|
||||
Allow,
|
||||
|
||||
[JsonPropertyName("review")]
|
||||
Review,
|
||||
|
||||
[JsonPropertyName("deny")]
|
||||
Deny,
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Schema;
|
||||
@@ -6,14 +8,54 @@ namespace StellaOps.Policy.RiskProfile.Schema;
|
||||
public static class RiskProfileSchemaProvider
|
||||
{
|
||||
private const string SchemaResource = "StellaOps.Policy.RiskProfile.Schemas.risk-profile-schema@1.json";
|
||||
private const string SchemaVersion = "1";
|
||||
|
||||
private static string? _cachedSchemaText;
|
||||
private static string? _cachedETag;
|
||||
|
||||
public static JsonSchema GetSchema()
|
||||
{
|
||||
var schemaText = GetSchemaText();
|
||||
return JsonSchema.FromText(schemaText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the raw JSON schema text.
|
||||
/// </summary>
|
||||
public static string GetSchemaText()
|
||||
{
|
||||
if (_cachedSchemaText is not null)
|
||||
{
|
||||
return _cachedSchemaText;
|
||||
}
|
||||
|
||||
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(SchemaResource)
|
||||
?? throw new InvalidOperationException($"Schema resource '{SchemaResource}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var schemaText = reader.ReadToEnd();
|
||||
_cachedSchemaText = reader.ReadToEnd();
|
||||
|
||||
return JsonSchema.FromText(schemaText);
|
||||
return _cachedSchemaText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the schema version identifier.
|
||||
/// </summary>
|
||||
public static string GetSchemaVersion() => SchemaVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Returns an ETag for the schema content.
|
||||
/// </summary>
|
||||
public static string GetETag()
|
||||
{
|
||||
if (_cachedETag is not null)
|
||||
{
|
||||
return _cachedETag;
|
||||
}
|
||||
|
||||
var schemaText = GetSchemaText();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaText));
|
||||
_cachedETag = $"\"{Convert.ToHexStringLower(hash)[..16]}\"";
|
||||
|
||||
return _cachedETag;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user