up
This commit is contained in:
@@ -28,7 +28,7 @@ public static class RiskProfileCanonicalizer
|
||||
|
||||
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(utf8Json, DocOptions);
|
||||
using var doc = JsonDocument.Parse(utf8Json.ToArray(), DocOptions);
|
||||
var canonical = CanonicalizeElement(doc.RootElement);
|
||||
return Encoding.UTF8.GetBytes(canonical);
|
||||
}
|
||||
@@ -103,11 +103,11 @@ public static class RiskProfileCanonicalizer
|
||||
}
|
||||
else if (IsSeverityOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
items = items.OrderBy(GetWhenThenKeyFromNode, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsDecisionOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
items = items.OrderBy(GetWhenThenKeyFromNode, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
array.Clear();
|
||||
@@ -303,6 +303,19 @@ public static class RiskProfileCanonicalizer
|
||||
return when + "|" + then;
|
||||
}
|
||||
|
||||
private static string GetWhenThenKeyFromNode(JsonNode? node)
|
||||
{
|
||||
if (node is null) return string.Empty;
|
||||
var obj = node.AsObject();
|
||||
var when = obj.TryGetPropertyValue("when", out var whenNode) && whenNode is not null ? whenNode.ToJsonString() : string.Empty;
|
||||
var then = obj.TryGetPropertyValue("set", out var setNode) && setNode is not null
|
||||
? setNode.ToJsonString()
|
||||
: obj.TryGetPropertyValue("action", out var actionNode) && actionNode is not null
|
||||
? actionNode.ToJsonString()
|
||||
: string.Empty;
|
||||
return when + "|" + then;
|
||||
}
|
||||
|
||||
private static bool IsSignals(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "signals";
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exported risk profile bundle with signature.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileBundle(
|
||||
[property: JsonPropertyName("bundle_id")] string BundleId,
|
||||
[property: JsonPropertyName("format_version")] string FormatVersion,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("profiles")] IReadOnlyList<ExportedProfile> Profiles,
|
||||
[property: JsonPropertyName("signature")] BundleSignature? Signature,
|
||||
[property: JsonPropertyName("metadata")] BundleMetadata Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// An exported profile with its lifecycle info.
|
||||
/// </summary>
|
||||
public sealed record ExportedProfile(
|
||||
[property: JsonPropertyName("profile")] RiskProfileModel Profile,
|
||||
[property: JsonPropertyName("lifecycle")] RiskProfileVersionInfo? Lifecycle,
|
||||
[property: JsonPropertyName("content_hash")] string ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// Signature for a profile bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleSignature(
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("key_id")] string? KeyId,
|
||||
[property: JsonPropertyName("value")] string Value,
|
||||
[property: JsonPropertyName("signed_at")] DateTimeOffset SignedAt,
|
||||
[property: JsonPropertyName("signed_by")] string? SignedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for an exported bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleMetadata(
|
||||
[property: JsonPropertyName("source_system")] string SourceSystem,
|
||||
[property: JsonPropertyName("source_version")] string SourceVersion,
|
||||
[property: JsonPropertyName("profile_count")] int ProfileCount,
|
||||
[property: JsonPropertyName("total_hash")] string TotalHash,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags);
|
||||
|
||||
/// <summary>
|
||||
/// Request to export profiles.
|
||||
/// </summary>
|
||||
public sealed record ExportProfilesRequest(
|
||||
[property: JsonPropertyName("profile_ids")] IReadOnlyList<string> ProfileIds,
|
||||
[property: JsonPropertyName("include_all_versions")] bool IncludeAllVersions = false,
|
||||
[property: JsonPropertyName("sign_bundle")] bool SignBundle = true,
|
||||
[property: JsonPropertyName("key_id")] string? KeyId = null,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to import profiles.
|
||||
/// </summary>
|
||||
public sealed record ImportProfilesRequest(
|
||||
[property: JsonPropertyName("bundle")] RiskProfileBundle Bundle,
|
||||
[property: JsonPropertyName("verify_signature")] bool VerifySignature = true,
|
||||
[property: JsonPropertyName("overwrite_existing")] bool OverwriteExisting = false,
|
||||
[property: JsonPropertyName("activate_on_import")] bool ActivateOnImport = false);
|
||||
|
||||
/// <summary>
|
||||
/// Result of import operation.
|
||||
/// </summary>
|
||||
public sealed record ImportResult(
|
||||
[property: JsonPropertyName("bundle_id")] string BundleId,
|
||||
[property: JsonPropertyName("imported_count")] int ImportedCount,
|
||||
[property: JsonPropertyName("skipped_count")] int SkippedCount,
|
||||
[property: JsonPropertyName("error_count")] int ErrorCount,
|
||||
[property: JsonPropertyName("details")] IReadOnlyList<ImportProfileResult> Details,
|
||||
[property: JsonPropertyName("signature_verified")] bool? SignatureVerified);
|
||||
|
||||
/// <summary>
|
||||
/// Result of importing a single profile.
|
||||
/// </summary>
|
||||
public sealed record ImportProfileResult(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("status")] ImportStatus Status,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Status of profile import.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ImportStatus>))]
|
||||
public enum ImportStatus
|
||||
{
|
||||
[JsonPropertyName("imported")]
|
||||
Imported,
|
||||
|
||||
[JsonPropertyName("skipped")]
|
||||
Skipped,
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
Error,
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
Updated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult(
|
||||
[property: JsonPropertyName("is_valid")] bool IsValid,
|
||||
[property: JsonPropertyName("algorithm")] string? Algorithm,
|
||||
[property: JsonPropertyName("key_id")] string? KeyId,
|
||||
[property: JsonPropertyName("signed_at")] DateTimeOffset? SignedAt,
|
||||
[property: JsonPropertyName("error")] string? Error);
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting and importing risk profiles with signatures.
|
||||
/// </summary>
|
||||
public sealed class ProfileExportService
|
||||
{
|
||||
private const string FormatVersion = "1.0";
|
||||
private const string SourceSystem = "StellaOps.Policy";
|
||||
private const string DefaultAlgorithm = "HMAC-SHA256";
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly Func<string, RiskProfileModel?>? _profileLookup;
|
||||
private readonly Func<string, RiskProfileVersionInfo?>? _lifecycleLookup;
|
||||
private readonly Action<RiskProfileModel>? _profileSave;
|
||||
private readonly Func<string, string?>? _keyLookup;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public ProfileExportService(
|
||||
TimeProvider? timeProvider = null,
|
||||
Func<string, RiskProfileModel?>? profileLookup = null,
|
||||
Func<string, RiskProfileVersionInfo?>? lifecycleLookup = null,
|
||||
Action<RiskProfileModel>? profileSave = null,
|
||||
Func<string, string?>? keyLookup = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_hasher = new RiskProfileHasher();
|
||||
_profileLookup = profileLookup;
|
||||
_lifecycleLookup = lifecycleLookup;
|
||||
_profileSave = profileSave;
|
||||
_keyLookup = keyLookup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports profiles to a signed bundle.
|
||||
/// </summary>
|
||||
public RiskProfileBundle Export(
|
||||
IReadOnlyList<RiskProfileModel> profiles,
|
||||
ExportProfilesRequest request,
|
||||
string? exportedBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profiles);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = GenerateBundleId(now);
|
||||
|
||||
var exportedProfiles = profiles.Select(p => new ExportedProfile(
|
||||
Profile: p,
|
||||
Lifecycle: _lifecycleLookup?.Invoke(p.Id),
|
||||
ContentHash: _hasher.ComputeContentHash(p)
|
||||
)).ToList();
|
||||
|
||||
var totalHash = ComputeTotalHash(exportedProfiles);
|
||||
|
||||
var metadata = new BundleMetadata(
|
||||
SourceSystem: SourceSystem,
|
||||
SourceVersion: GetSourceVersion(),
|
||||
ProfileCount: exportedProfiles.Count,
|
||||
TotalHash: totalHash,
|
||||
Description: request.Description,
|
||||
Tags: request.Tags);
|
||||
|
||||
BundleSignature? signature = null;
|
||||
if (request.SignBundle)
|
||||
{
|
||||
signature = SignBundle(exportedProfiles, metadata, request.KeyId, exportedBy, now);
|
||||
}
|
||||
|
||||
return new RiskProfileBundle(
|
||||
BundleId: bundleId,
|
||||
FormatVersion: FormatVersion,
|
||||
CreatedAt: now,
|
||||
CreatedBy: exportedBy,
|
||||
Profiles: exportedProfiles.AsReadOnly(),
|
||||
Signature: signature,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports profiles from a bundle.
|
||||
/// </summary>
|
||||
public ImportResult Import(
|
||||
ImportProfilesRequest request,
|
||||
string? importedBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.Bundle);
|
||||
|
||||
var bundle = request.Bundle;
|
||||
var details = new List<ImportProfileResult>();
|
||||
var importedCount = 0;
|
||||
var skippedCount = 0;
|
||||
var errorCount = 0;
|
||||
bool? signatureVerified = null;
|
||||
|
||||
// Verify signature if requested
|
||||
if (request.VerifySignature && bundle.Signature != null)
|
||||
{
|
||||
var verification = VerifySignature(bundle);
|
||||
signatureVerified = verification.IsValid;
|
||||
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
return new ImportResult(
|
||||
BundleId: bundle.BundleId,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Profiles.Count,
|
||||
Details: bundle.Profiles.Select(p => new ImportProfileResult(
|
||||
ProfileId: p.Profile.Id,
|
||||
Version: p.Profile.Version,
|
||||
Status: ImportStatus.Error,
|
||||
Message: $"Signature verification failed: {verification.Error}"
|
||||
)).ToList().AsReadOnly(),
|
||||
SignatureVerified: false);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var exported in bundle.Profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Verify content hash
|
||||
var computedHash = _hasher.ComputeContentHash(exported.Profile);
|
||||
if (computedHash != exported.ContentHash)
|
||||
{
|
||||
details.Add(new ImportProfileResult(
|
||||
ProfileId: exported.Profile.Id,
|
||||
Version: exported.Profile.Version,
|
||||
Status: ImportStatus.Error,
|
||||
Message: "Content hash mismatch - profile may have been tampered with."));
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if profile already exists
|
||||
var existing = _profileLookup?.Invoke(exported.Profile.Id);
|
||||
if (existing != null && !request.OverwriteExisting)
|
||||
{
|
||||
details.Add(new ImportProfileResult(
|
||||
ProfileId: exported.Profile.Id,
|
||||
Version: exported.Profile.Version,
|
||||
Status: ImportStatus.Skipped,
|
||||
Message: "Profile already exists and overwrite not enabled."));
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save profile
|
||||
_profileSave?.Invoke(exported.Profile);
|
||||
|
||||
var status = existing != null ? ImportStatus.Updated : ImportStatus.Imported;
|
||||
details.Add(new ImportProfileResult(
|
||||
ProfileId: exported.Profile.Id,
|
||||
Version: exported.Profile.Version,
|
||||
Status: status,
|
||||
Message: null));
|
||||
|
||||
importedCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
details.Add(new ImportProfileResult(
|
||||
ProfileId: exported.Profile.Id,
|
||||
Version: exported.Profile.Version,
|
||||
Status: ImportStatus.Error,
|
||||
Message: ex.Message));
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ImportResult(
|
||||
BundleId: bundle.BundleId,
|
||||
ImportedCount: importedCount,
|
||||
SkippedCount: skippedCount,
|
||||
ErrorCount: errorCount,
|
||||
Details: details.AsReadOnly(),
|
||||
SignatureVerified: signatureVerified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the signature of a bundle.
|
||||
/// </summary>
|
||||
public SignatureVerificationResult VerifySignature(RiskProfileBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
if (bundle.Signature == null)
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Algorithm: null,
|
||||
KeyId: null,
|
||||
SignedAt: null,
|
||||
Error: "Bundle has no signature.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the signing key
|
||||
var key = bundle.Signature.KeyId != null
|
||||
? _keyLookup?.Invoke(bundle.Signature.KeyId)
|
||||
: GetDefaultSigningKey();
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Algorithm: bundle.Signature.Algorithm,
|
||||
KeyId: bundle.Signature.KeyId,
|
||||
SignedAt: bundle.Signature.SignedAt,
|
||||
Error: "Signing key not found.");
|
||||
}
|
||||
|
||||
// Compute expected signature
|
||||
var data = ComputeSignatureData(bundle.Profiles.ToList(), bundle.Metadata);
|
||||
var expectedSignature = ComputeHmacSignature(data, key);
|
||||
|
||||
var isValid = string.Equals(expectedSignature, bundle.Signature.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: isValid,
|
||||
Algorithm: bundle.Signature.Algorithm,
|
||||
KeyId: bundle.Signature.KeyId,
|
||||
SignedAt: bundle.Signature.SignedAt,
|
||||
Error: isValid ? null : "Signature does not match.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Algorithm: bundle.Signature.Algorithm,
|
||||
KeyId: bundle.Signature.KeyId,
|
||||
SignedAt: bundle.Signature.SignedAt,
|
||||
Error: $"Verification error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a bundle to JSON.
|
||||
/// </summary>
|
||||
public string SerializeBundle(RiskProfileBundle bundle)
|
||||
{
|
||||
return JsonSerializer.Serialize(bundle, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a bundle from JSON.
|
||||
/// </summary>
|
||||
public RiskProfileBundle? DeserializeBundle(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<RiskProfileBundle>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private BundleSignature SignBundle(
|
||||
IReadOnlyList<ExportedProfile> profiles,
|
||||
BundleMetadata metadata,
|
||||
string? keyId,
|
||||
string? signedBy,
|
||||
DateTimeOffset signedAt)
|
||||
{
|
||||
var key = keyId != null
|
||||
? _keyLookup?.Invoke(keyId)
|
||||
: GetDefaultSigningKey();
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
// Use a default key for development/testing
|
||||
key = GetDefaultSigningKey();
|
||||
}
|
||||
|
||||
var data = ComputeSignatureData(profiles.ToList(), metadata);
|
||||
var signatureValue = ComputeHmacSignature(data, key);
|
||||
|
||||
return new BundleSignature(
|
||||
Algorithm: DefaultAlgorithm,
|
||||
KeyId: keyId,
|
||||
Value: signatureValue,
|
||||
SignedAt: signedAt,
|
||||
SignedBy: signedBy);
|
||||
}
|
||||
|
||||
private static string ComputeSignatureData(List<ExportedProfile> profiles, BundleMetadata metadata)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Include all content hashes in order
|
||||
foreach (var profile in profiles.OrderBy(p => p.Profile.Id).ThenBy(p => p.Profile.Version))
|
||||
{
|
||||
sb.Append(profile.ContentHash);
|
||||
sb.Append('|');
|
||||
}
|
||||
|
||||
// Include metadata
|
||||
sb.Append(metadata.TotalHash);
|
||||
sb.Append('|');
|
||||
sb.Append(metadata.ProfileCount);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string data, string key)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key);
|
||||
var dataBytes = Encoding.UTF8.GetBytes(data);
|
||||
|
||||
using var hmac = new HMACSHA256(keyBytes);
|
||||
var hashBytes = hmac.ComputeHash(dataBytes);
|
||||
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private string ComputeTotalHash(IReadOnlyList<ExportedProfile> profiles)
|
||||
{
|
||||
var combined = string.Join("|", profiles
|
||||
.OrderBy(p => p.Profile.Id)
|
||||
.ThenBy(p => p.Profile.Version)
|
||||
.Select(p => p.ContentHash));
|
||||
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rpb-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static string GetSourceVersion()
|
||||
{
|
||||
return typeof(ProfileExportService).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
|
||||
private static string GetDefaultSigningKey()
|
||||
{
|
||||
// In production, this would come from secure key management
|
||||
// For now, use a placeholder that should be overridden
|
||||
return "stellaops-default-signing-key-change-in-production";
|
||||
}
|
||||
}
|
||||
@@ -480,8 +480,10 @@ public sealed class RiskProfileLifecycleService
|
||||
|
||||
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;
|
||||
object? fromValue = null;
|
||||
object? toValue = null;
|
||||
var fromHas = from.Metadata?.TryGetValue(key, out fromValue) ?? false;
|
||||
var toHas = to.Metadata?.TryGetValue(key, out toValue) ?? false;
|
||||
|
||||
if (fromHas != toHas || (fromHas && toHas && !Equals(fromValue, toValue)))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Overrides;
|
||||
|
||||
/// <summary>
|
||||
/// An override with full audit metadata.
|
||||
/// </summary>
|
||||
public sealed record AuditedOverride(
|
||||
[property: JsonPropertyName("override_id")] string OverrideId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("override_type")] OverrideType OverrideType,
|
||||
[property: JsonPropertyName("predicate")] OverridePredicate Predicate,
|
||||
[property: JsonPropertyName("action")] OverrideAction Action,
|
||||
[property: JsonPropertyName("priority")] int Priority,
|
||||
[property: JsonPropertyName("audit")] OverrideAuditMetadata Audit,
|
||||
[property: JsonPropertyName("status")] OverrideStatus Status,
|
||||
[property: JsonPropertyName("expiration")] DateTimeOffset? Expiration = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<OverrideType>))]
|
||||
public enum OverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// Override the computed severity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
Severity,
|
||||
|
||||
/// <summary>
|
||||
/// Override the recommended action/decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
Decision,
|
||||
|
||||
/// <summary>
|
||||
/// Override a signal weight.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
Weight,
|
||||
|
||||
/// <summary>
|
||||
/// Exception that exempts from policy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exception")]
|
||||
Exception
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<OverrideStatus>))]
|
||||
public enum OverrideStatus
|
||||
{
|
||||
[JsonPropertyName("active")]
|
||||
Active,
|
||||
|
||||
[JsonPropertyName("disabled")]
|
||||
Disabled,
|
||||
|
||||
[JsonPropertyName("expired")]
|
||||
Expired,
|
||||
|
||||
[JsonPropertyName("superseded")]
|
||||
Superseded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for when an override applies.
|
||||
/// </summary>
|
||||
public sealed record OverridePredicate(
|
||||
[property: JsonPropertyName("conditions")] IReadOnlyList<OverrideCondition> Conditions,
|
||||
[property: JsonPropertyName("match_mode")] PredicateMatchMode MatchMode = PredicateMatchMode.All);
|
||||
|
||||
/// <summary>
|
||||
/// A single condition in an override predicate.
|
||||
/// </summary>
|
||||
public sealed record OverrideCondition(
|
||||
[property: JsonPropertyName("field")] string Field,
|
||||
[property: JsonPropertyName("operator")] ConditionOperator Operator,
|
||||
[property: JsonPropertyName("value")] object? Value);
|
||||
|
||||
/// <summary>
|
||||
/// Condition operator.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConditionOperator>))]
|
||||
public enum ConditionOperator
|
||||
{
|
||||
[JsonPropertyName("eq")]
|
||||
Equals,
|
||||
|
||||
[JsonPropertyName("neq")]
|
||||
NotEquals,
|
||||
|
||||
[JsonPropertyName("gt")]
|
||||
GreaterThan,
|
||||
|
||||
[JsonPropertyName("gte")]
|
||||
GreaterThanOrEqual,
|
||||
|
||||
[JsonPropertyName("lt")]
|
||||
LessThan,
|
||||
|
||||
[JsonPropertyName("lte")]
|
||||
LessThanOrEqual,
|
||||
|
||||
[JsonPropertyName("in")]
|
||||
In,
|
||||
|
||||
[JsonPropertyName("nin")]
|
||||
NotIn,
|
||||
|
||||
[JsonPropertyName("contains")]
|
||||
Contains,
|
||||
|
||||
[JsonPropertyName("regex")]
|
||||
Regex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate match mode.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PredicateMatchMode>))]
|
||||
public enum PredicateMatchMode
|
||||
{
|
||||
/// <summary>
|
||||
/// All conditions must match.
|
||||
/// </summary>
|
||||
[JsonPropertyName("all")]
|
||||
All,
|
||||
|
||||
/// <summary>
|
||||
/// Any condition must match.
|
||||
/// </summary>
|
||||
[JsonPropertyName("any")]
|
||||
Any
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when override matches.
|
||||
/// </summary>
|
||||
public sealed record OverrideAction(
|
||||
[property: JsonPropertyName("action_type")] OverrideActionType ActionType,
|
||||
[property: JsonPropertyName("severity")] RiskSeverity? Severity = null,
|
||||
[property: JsonPropertyName("decision")] RiskAction? Decision = null,
|
||||
[property: JsonPropertyName("weight_factor")] double? WeightFactor = null,
|
||||
[property: JsonPropertyName("reason")] string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of override action.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<OverrideActionType>))]
|
||||
public enum OverrideActionType
|
||||
{
|
||||
[JsonPropertyName("set_severity")]
|
||||
SetSeverity,
|
||||
|
||||
[JsonPropertyName("set_decision")]
|
||||
SetDecision,
|
||||
|
||||
[JsonPropertyName("adjust_weight")]
|
||||
AdjustWeight,
|
||||
|
||||
[JsonPropertyName("exempt")]
|
||||
Exempt,
|
||||
|
||||
[JsonPropertyName("suppress")]
|
||||
Suppress
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit metadata for an override.
|
||||
/// </summary>
|
||||
public sealed record OverrideAuditMetadata(
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("ticket_ref")] string? TicketRef,
|
||||
[property: JsonPropertyName("approved_by")] string? ApprovedBy,
|
||||
[property: JsonPropertyName("approved_at")] DateTimeOffset? ApprovedAt,
|
||||
[property: JsonPropertyName("review_required")] bool ReviewRequired = false,
|
||||
[property: JsonPropertyName("last_modified_at")] DateTimeOffset? LastModifiedAt = null,
|
||||
[property: JsonPropertyName("last_modified_by")] string? LastModifiedBy = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an override.
|
||||
/// </summary>
|
||||
public sealed record CreateOverrideRequest(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("override_type")] OverrideType OverrideType,
|
||||
[property: JsonPropertyName("predicate")] OverridePredicate Predicate,
|
||||
[property: JsonPropertyName("action")] OverrideAction Action,
|
||||
[property: JsonPropertyName("priority")] int? Priority,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("ticket_ref")] string? TicketRef,
|
||||
[property: JsonPropertyName("expiration")] DateTimeOffset? Expiration,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags,
|
||||
[property: JsonPropertyName("review_required")] bool ReviewRequired = false);
|
||||
|
||||
/// <summary>
|
||||
/// Result of override conflict validation.
|
||||
/// </summary>
|
||||
public sealed record OverrideConflictValidation(
|
||||
[property: JsonPropertyName("has_conflicts")] bool HasConflicts,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<OverrideConflict> Conflicts,
|
||||
[property: JsonPropertyName("warnings")] IReadOnlyList<string> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Details of a conflict between overrides.
|
||||
/// </summary>
|
||||
public sealed record OverrideConflict(
|
||||
[property: JsonPropertyName("override_id")] string OverrideId,
|
||||
[property: JsonPropertyName("conflict_type")] ConflictType ConflictType,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("resolution")] ConflictResolution Resolution);
|
||||
|
||||
/// <summary>
|
||||
/// Type of conflict.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConflictType>))]
|
||||
public enum ConflictType
|
||||
{
|
||||
[JsonPropertyName("same_predicate")]
|
||||
SamePredicate,
|
||||
|
||||
[JsonPropertyName("overlapping_predicate")]
|
||||
OverlappingPredicate,
|
||||
|
||||
[JsonPropertyName("contradictory_action")]
|
||||
ContradictoryAction,
|
||||
|
||||
[JsonPropertyName("priority_collision")]
|
||||
PriorityCollision
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolution for a conflict.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConflictResolution>))]
|
||||
public enum ConflictResolution
|
||||
{
|
||||
[JsonPropertyName("higher_priority_wins")]
|
||||
HigherPriorityWins,
|
||||
|
||||
[JsonPropertyName("newer_wins")]
|
||||
NewerWins,
|
||||
|
||||
[JsonPropertyName("manual_review_required")]
|
||||
ManualReviewRequired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override application record for audit trail.
|
||||
/// </summary>
|
||||
public sealed record OverrideApplicationRecord(
|
||||
[property: JsonPropertyName("override_id")] string OverrideId,
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("applied_at")] DateTimeOffset AppliedAt,
|
||||
[property: JsonPropertyName("original_value")] object? OriginalValue,
|
||||
[property: JsonPropertyName("applied_value")] object? AppliedValue,
|
||||
[property: JsonPropertyName("context")] Dictionary<string, object?> Context);
|
||||
@@ -0,0 +1,570 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Overrides;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing overrides with audit metadata and conflict validation.
|
||||
/// </summary>
|
||||
public sealed class OverrideService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, AuditedOverride> _overrides;
|
||||
private readonly ConcurrentDictionary<string, List<string>> _profileIndex;
|
||||
private readonly ConcurrentDictionary<string, List<OverrideApplicationRecord>> _applicationHistory;
|
||||
|
||||
public OverrideService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_overrides = new ConcurrentDictionary<string, AuditedOverride>(StringComparer.OrdinalIgnoreCase);
|
||||
_profileIndex = new ConcurrentDictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
_applicationHistory = new ConcurrentDictionary<string, List<OverrideApplicationRecord>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new override with audit metadata.
|
||||
/// </summary>
|
||||
public AuditedOverride Create(CreateOverrideRequest request, string? createdBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
throw new ArgumentException("ProfileId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
throw new ArgumentException("Reason is required for audit purposes.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var overrideId = GenerateOverrideId(request, now);
|
||||
|
||||
var audit = new OverrideAuditMetadata(
|
||||
CreatedAt: now,
|
||||
CreatedBy: createdBy,
|
||||
Reason: request.Reason,
|
||||
Justification: request.Justification,
|
||||
TicketRef: request.TicketRef,
|
||||
ApprovedBy: null,
|
||||
ApprovedAt: null,
|
||||
ReviewRequired: request.ReviewRequired);
|
||||
|
||||
var auditedOverride = new AuditedOverride(
|
||||
OverrideId: overrideId,
|
||||
ProfileId: request.ProfileId,
|
||||
OverrideType: request.OverrideType,
|
||||
Predicate: request.Predicate,
|
||||
Action: request.Action,
|
||||
Priority: request.Priority ?? 100,
|
||||
Audit: audit,
|
||||
Status: request.ReviewRequired ? OverrideStatus.Disabled : OverrideStatus.Active,
|
||||
Expiration: request.Expiration,
|
||||
Tags: request.Tags);
|
||||
|
||||
_overrides[overrideId] = auditedOverride;
|
||||
IndexOverride(auditedOverride);
|
||||
|
||||
return auditedOverride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an override by ID.
|
||||
/// </summary>
|
||||
public AuditedOverride? Get(string overrideId)
|
||||
{
|
||||
return _overrides.TryGetValue(overrideId, out var over) ? over : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists overrides for a profile.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuditedOverride> ListByProfile(string profileId, bool includeInactive = false)
|
||||
{
|
||||
if (!_profileIndex.TryGetValue(profileId, out var ids))
|
||||
{
|
||||
return Array.Empty<AuditedOverride>();
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (ids)
|
||||
{
|
||||
var overrides = ids
|
||||
.Select(id => _overrides.TryGetValue(id, out var o) ? o : null)
|
||||
.Where(o => o != null)
|
||||
.Cast<AuditedOverride>();
|
||||
|
||||
if (!includeInactive)
|
||||
{
|
||||
overrides = overrides.Where(o => IsActive(o, now));
|
||||
}
|
||||
|
||||
return overrides
|
||||
.OrderByDescending(o => o.Priority)
|
||||
.ThenByDescending(o => o.Audit.CreatedAt)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an override for conflicts with existing overrides.
|
||||
/// </summary>
|
||||
public OverrideConflictValidation ValidateConflicts(CreateOverrideRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var conflicts = new List<OverrideConflict>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
var existingOverrides = ListByProfile(request.ProfileId, includeInactive: false);
|
||||
|
||||
foreach (var existing in existingOverrides)
|
||||
{
|
||||
// Check for same predicate
|
||||
if (PredicatesEqual(request.Predicate, existing.Predicate))
|
||||
{
|
||||
conflicts.Add(new OverrideConflict(
|
||||
OverrideId: existing.OverrideId,
|
||||
ConflictType: ConflictType.SamePredicate,
|
||||
Description: $"Override {existing.OverrideId} has identical predicate conditions.",
|
||||
Resolution: ConflictResolution.HigherPriorityWins));
|
||||
}
|
||||
// Check for overlapping predicate
|
||||
else if (PredicatesOverlap(request.Predicate, existing.Predicate))
|
||||
{
|
||||
warnings.Add($"Override may overlap with {existing.OverrideId}. Consider reviewing priority settings.");
|
||||
|
||||
// Check for contradictory actions
|
||||
if (ActionsContradict(request.Action, existing.Action))
|
||||
{
|
||||
conflicts.Add(new OverrideConflict(
|
||||
OverrideId: existing.OverrideId,
|
||||
ConflictType: ConflictType.ContradictoryAction,
|
||||
Description: $"Override {existing.OverrideId} has contradictory action for overlapping conditions.",
|
||||
Resolution: ConflictResolution.ManualReviewRequired));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for priority collision
|
||||
if (request.Priority == existing.Priority && PredicatesOverlap(request.Predicate, existing.Predicate))
|
||||
{
|
||||
conflicts.Add(new OverrideConflict(
|
||||
OverrideId: existing.OverrideId,
|
||||
ConflictType: ConflictType.PriorityCollision,
|
||||
Description: $"Override {existing.OverrideId} has same priority and overlapping conditions.",
|
||||
Resolution: ConflictResolution.NewerWins));
|
||||
}
|
||||
}
|
||||
|
||||
return new OverrideConflictValidation(
|
||||
HasConflicts: conflicts.Count > 0,
|
||||
Conflicts: conflicts.AsReadOnly(),
|
||||
Warnings: warnings.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves an override that requires review.
|
||||
/// </summary>
|
||||
public AuditedOverride? Approve(string overrideId, string approvedBy)
|
||||
{
|
||||
if (!_overrides.TryGetValue(overrideId, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existing.Audit.ReviewRequired)
|
||||
{
|
||||
throw new InvalidOperationException("Override does not require approval.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = existing with
|
||||
{
|
||||
Status = OverrideStatus.Active,
|
||||
Audit = existing.Audit with
|
||||
{
|
||||
ApprovedBy = approvedBy,
|
||||
ApprovedAt = now,
|
||||
LastModifiedAt = now,
|
||||
LastModifiedBy = approvedBy
|
||||
}
|
||||
};
|
||||
|
||||
_overrides[overrideId] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables an override.
|
||||
/// </summary>
|
||||
public AuditedOverride? Disable(string overrideId, string disabledBy, string? reason = null)
|
||||
{
|
||||
if (!_overrides.TryGetValue(overrideId, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = existing with
|
||||
{
|
||||
Status = OverrideStatus.Disabled,
|
||||
Audit = existing.Audit with
|
||||
{
|
||||
LastModifiedAt = now,
|
||||
LastModifiedBy = disabledBy
|
||||
}
|
||||
};
|
||||
|
||||
_overrides[overrideId] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an override.
|
||||
/// </summary>
|
||||
public bool Delete(string overrideId)
|
||||
{
|
||||
if (_overrides.TryRemove(overrideId, out var removed))
|
||||
{
|
||||
RemoveFromIndex(removed);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an override application for audit trail.
|
||||
/// </summary>
|
||||
public void RecordApplication(
|
||||
string overrideId,
|
||||
string findingId,
|
||||
object? originalValue,
|
||||
object? appliedValue,
|
||||
Dictionary<string, object?>? context = null)
|
||||
{
|
||||
var record = new OverrideApplicationRecord(
|
||||
OverrideId: overrideId,
|
||||
FindingId: findingId,
|
||||
AppliedAt: _timeProvider.GetUtcNow(),
|
||||
OriginalValue: originalValue,
|
||||
AppliedValue: appliedValue,
|
||||
Context: context ?? new Dictionary<string, object?>());
|
||||
|
||||
var history = _applicationHistory.GetOrAdd(overrideId, _ => new List<OverrideApplicationRecord>());
|
||||
lock (history)
|
||||
{
|
||||
history.Add(record);
|
||||
|
||||
// Keep only last 1000 records per override
|
||||
if (history.Count > 1000)
|
||||
{
|
||||
history.RemoveRange(0, history.Count - 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets application history for an override.
|
||||
/// </summary>
|
||||
public IReadOnlyList<OverrideApplicationRecord> GetApplicationHistory(string overrideId, int limit = 100)
|
||||
{
|
||||
if (!_applicationHistory.TryGetValue(overrideId, out var history))
|
||||
{
|
||||
return Array.Empty<OverrideApplicationRecord>();
|
||||
}
|
||||
|
||||
lock (history)
|
||||
{
|
||||
return history
|
||||
.OrderByDescending(r => r.AppliedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a finding matches an override's predicate.
|
||||
/// </summary>
|
||||
public bool EvaluatePredicate(OverridePredicate predicate, Dictionary<string, object?> signals)
|
||||
{
|
||||
if (predicate.Conditions.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var results = predicate.Conditions.Select(c => EvaluateCondition(c, signals));
|
||||
|
||||
return predicate.MatchMode == PredicateMatchMode.All
|
||||
? results.All(r => r)
|
||||
: results.Any(r => r);
|
||||
}
|
||||
|
||||
private bool EvaluateCondition(OverrideCondition condition, Dictionary<string, object?> signals)
|
||||
{
|
||||
if (!signals.TryGetValue(condition.Field, out var actualValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return condition.Operator switch
|
||||
{
|
||||
ConditionOperator.Equals => ValuesEqual(actualValue, condition.Value),
|
||||
ConditionOperator.NotEquals => !ValuesEqual(actualValue, condition.Value),
|
||||
ConditionOperator.GreaterThan => CompareValues(actualValue, condition.Value) > 0,
|
||||
ConditionOperator.GreaterThanOrEqual => CompareValues(actualValue, condition.Value) >= 0,
|
||||
ConditionOperator.LessThan => CompareValues(actualValue, condition.Value) < 0,
|
||||
ConditionOperator.LessThanOrEqual => CompareValues(actualValue, condition.Value) <= 0,
|
||||
ConditionOperator.In => IsInCollection(actualValue, condition.Value),
|
||||
ConditionOperator.NotIn => !IsInCollection(actualValue, condition.Value),
|
||||
ConditionOperator.Contains => ContainsValue(actualValue, condition.Value),
|
||||
ConditionOperator.Regex => MatchesRegex(actualValue, condition.Value),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsActive(AuditedOverride over, DateTimeOffset asOf)
|
||||
{
|
||||
if (over.Status != OverrideStatus.Active)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (over.Expiration.HasValue && asOf > over.Expiration.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PredicatesEqual(OverridePredicate a, OverridePredicate b)
|
||||
{
|
||||
if (a.MatchMode != b.MatchMode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.Conditions.Count != b.Conditions.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var aConditions = a.Conditions.OrderBy(c => c.Field).ThenBy(c => c.Operator.ToString()).ToList();
|
||||
var bConditions = b.Conditions.OrderBy(c => c.Field).ThenBy(c => c.Operator.ToString()).ToList();
|
||||
|
||||
for (var i = 0; i < aConditions.Count; i++)
|
||||
{
|
||||
if (aConditions[i].Field != bConditions[i].Field ||
|
||||
aConditions[i].Operator != bConditions[i].Operator ||
|
||||
!ValuesEqual(aConditions[i].Value, bConditions[i].Value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PredicatesOverlap(OverridePredicate a, OverridePredicate b)
|
||||
{
|
||||
// Simplified overlap check: if any fields match, consider them overlapping
|
||||
var aFields = a.Conditions.Select(c => c.Field).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var bFields = b.Conditions.Select(c => c.Field).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return aFields.Overlaps(bFields);
|
||||
}
|
||||
|
||||
private static bool ActionsContradict(OverrideAction a, OverrideAction b)
|
||||
{
|
||||
// Severity actions contradict if they set different severities
|
||||
if (a.ActionType == OverrideActionType.SetSeverity &&
|
||||
b.ActionType == OverrideActionType.SetSeverity &&
|
||||
a.Severity != b.Severity)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decision actions contradict if they set different decisions
|
||||
if (a.ActionType == OverrideActionType.SetDecision &&
|
||||
b.ActionType == OverrideActionType.SetDecision &&
|
||||
a.Decision != b.Decision)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exempt and Suppress contradict with any severity/decision setting
|
||||
if ((a.ActionType == OverrideActionType.Exempt || a.ActionType == OverrideActionType.Suppress) &&
|
||||
(b.ActionType == OverrideActionType.SetSeverity || b.ActionType == OverrideActionType.SetDecision))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((b.ActionType == OverrideActionType.Exempt || b.ActionType == OverrideActionType.Suppress) &&
|
||||
(a.ActionType == OverrideActionType.SetSeverity || a.ActionType == OverrideActionType.SetDecision))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object? a, object? b)
|
||||
{
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
if (a is JsonElement jeA && b is JsonElement jeB)
|
||||
{
|
||||
return jeA.GetRawText() == jeB.GetRawText();
|
||||
}
|
||||
|
||||
var aStr = ConvertToString(a);
|
||||
var bStr = ConvertToString(b);
|
||||
|
||||
return string.Equals(aStr, bStr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int CompareValues(object? a, object? b)
|
||||
{
|
||||
var aNum = ConvertToDouble(a);
|
||||
var bNum = ConvertToDouble(b);
|
||||
|
||||
if (aNum.HasValue && bNum.HasValue)
|
||||
{
|
||||
return aNum.Value.CompareTo(bNum.Value);
|
||||
}
|
||||
|
||||
var aStr = ConvertToString(a);
|
||||
var bStr = ConvertToString(b);
|
||||
|
||||
return string.Compare(aStr, bStr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsInCollection(object? actual, object? collection)
|
||||
{
|
||||
if (collection == null) return false;
|
||||
|
||||
IEnumerable<string>? items = null;
|
||||
|
||||
if (collection is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
items = je.EnumerateArray().Select(e => ConvertToString(e));
|
||||
}
|
||||
else if (collection is IEnumerable<object> enumerable)
|
||||
{
|
||||
items = enumerable.Select(ConvertToString);
|
||||
}
|
||||
else if (collection is string str)
|
||||
{
|
||||
items = str.Split(',').Select(s => s.Trim());
|
||||
}
|
||||
|
||||
if (items == null) return false;
|
||||
|
||||
var actualStr = ConvertToString(actual);
|
||||
return items.Any(i => string.Equals(i, actualStr, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool ContainsValue(object? actual, object? search)
|
||||
{
|
||||
var actualStr = ConvertToString(actual);
|
||||
var searchStr = ConvertToString(search);
|
||||
|
||||
return actualStr.Contains(searchStr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchesRegex(object? actual, object? pattern)
|
||||
{
|
||||
var actualStr = ConvertToString(actual);
|
||||
var patternStr = ConvertToString(pattern);
|
||||
|
||||
try
|
||||
{
|
||||
return Regex.IsMatch(actualStr, patternStr, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertToString(object? value)
|
||||
{
|
||||
if (value == null) return string.Empty;
|
||||
|
||||
if (value is JsonElement je)
|
||||
{
|
||||
return je.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => je.GetString() ?? string.Empty,
|
||||
JsonValueKind.Number => je.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => string.Empty,
|
||||
_ => je.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static double? ConvertToDouble(object? value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
|
||||
if (value is JsonElement je && je.TryGetDouble(out var d))
|
||||
{
|
||||
return d;
|
||||
}
|
||||
|
||||
if (value is double dVal) return dVal;
|
||||
if (value is float fVal) return fVal;
|
||||
if (value is int iVal) return iVal;
|
||||
if (value is long lVal) return lVal;
|
||||
if (value is decimal decVal) return (double)decVal;
|
||||
|
||||
if (value is string str && double.TryParse(str, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void IndexOverride(AuditedOverride over)
|
||||
{
|
||||
var list = _profileIndex.GetOrAdd(over.ProfileId, _ => new List<string>());
|
||||
lock (list)
|
||||
{
|
||||
if (!list.Contains(over.OverrideId))
|
||||
{
|
||||
list.Add(over.OverrideId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveFromIndex(AuditedOverride over)
|
||||
{
|
||||
if (_profileIndex.TryGetValue(over.ProfileId, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
list.Remove(over.OverrideId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateOverrideId(CreateOverrideRequest request, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{request.ProfileId}|{request.OverrideType}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"ovr-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an attachment of a risk profile to a scope (organization, project, environment).
|
||||
/// </summary>
|
||||
public sealed record ScopeAttachment(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("scope_type")] ScopeType ScopeType,
|
||||
[property: JsonPropertyName("scope_id")] string ScopeId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("precedence")] int Precedence,
|
||||
[property: JsonPropertyName("effective_from")] DateTimeOffset EffectiveFrom,
|
||||
[property: JsonPropertyName("effective_until")] DateTimeOffset? EffectiveUntil,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("metadata")] Dictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of scope for profile attachment.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ScopeType>))]
|
||||
public enum ScopeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Global scope - applies to all unless overridden.
|
||||
/// </summary>
|
||||
[JsonPropertyName("global")]
|
||||
Global,
|
||||
|
||||
/// <summary>
|
||||
/// Organization-level scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("organization")]
|
||||
Organization,
|
||||
|
||||
/// <summary>
|
||||
/// Project-level scope within an organization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("project")]
|
||||
Project,
|
||||
|
||||
/// <summary>
|
||||
/// Environment-level scope (e.g., production, staging).
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
Environment,
|
||||
|
||||
/// <summary>
|
||||
/// Component-level scope for specific packages/images.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component")]
|
||||
Component
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a scope attachment.
|
||||
/// </summary>
|
||||
public sealed record CreateScopeAttachmentRequest(
|
||||
[property: JsonPropertyName("scope_type")] ScopeType ScopeType,
|
||||
[property: JsonPropertyName("scope_id")] string ScopeId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string? ProfileVersion,
|
||||
[property: JsonPropertyName("precedence")] int? Precedence,
|
||||
[property: JsonPropertyName("effective_from")] DateTimeOffset? EffectiveFrom,
|
||||
[property: JsonPropertyName("effective_until")] DateTimeOffset? EffectiveUntil,
|
||||
[property: JsonPropertyName("metadata")] Dictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Query for finding scope attachments.
|
||||
/// </summary>
|
||||
public sealed record ScopeAttachmentQuery(
|
||||
[property: JsonPropertyName("scope_type")] ScopeType? ScopeType = null,
|
||||
[property: JsonPropertyName("scope_id")] string? ScopeId = null,
|
||||
[property: JsonPropertyName("profile_id")] string? ProfileId = null,
|
||||
[property: JsonPropertyName("include_expired")] bool IncludeExpired = false,
|
||||
[property: JsonPropertyName("limit")] int Limit = 100);
|
||||
|
||||
/// <summary>
|
||||
/// Response containing resolved profile for a scope hierarchy.
|
||||
/// </summary>
|
||||
public sealed record ResolvedScopeProfile(
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("resolved_from")] ScopeType ResolvedFrom,
|
||||
[property: JsonPropertyName("scope_id")] string ScopeId,
|
||||
[property: JsonPropertyName("attachment_id")] string AttachmentId,
|
||||
[property: JsonPropertyName("inheritance_chain")] IReadOnlyList<ScopeAttachment> InheritanceChain);
|
||||
|
||||
/// <summary>
|
||||
/// Scope selector for matching components to profiles.
|
||||
/// </summary>
|
||||
public sealed record ScopeSelector(
|
||||
[property: JsonPropertyName("organization_id")] string? OrganizationId = null,
|
||||
[property: JsonPropertyName("project_id")] string? ProjectId = null,
|
||||
[property: JsonPropertyName("environment")] string? Environment = null,
|
||||
[property: JsonPropertyName("component_purl")] string? ComponentPurl = null,
|
||||
[property: JsonPropertyName("labels")] Dictionary<string, string>? Labels = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of scope resolution.
|
||||
/// </summary>
|
||||
public sealed record ScopeResolutionResult(
|
||||
[property: JsonPropertyName("selector")] ScopeSelector Selector,
|
||||
[property: JsonPropertyName("resolved_profile")] ResolvedScopeProfile? ResolvedProfile,
|
||||
[property: JsonPropertyName("applicable_attachments")] IReadOnlyList<ScopeAttachment> ApplicableAttachments,
|
||||
[property: JsonPropertyName("resolution_time_ms")] double ResolutionTimeMs);
|
||||
@@ -0,0 +1,339 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing risk profile scope attachments and resolution.
|
||||
/// </summary>
|
||||
public sealed class ScopeAttachmentService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, ScopeAttachment> _attachments;
|
||||
private readonly ConcurrentDictionary<string, List<string>> _scopeIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Precedence weights for scope types (higher = more specific, wins over lower).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<ScopeType, int> ScopePrecedence = new()
|
||||
{
|
||||
[ScopeType.Global] = 0,
|
||||
[ScopeType.Organization] = 100,
|
||||
[ScopeType.Project] = 200,
|
||||
[ScopeType.Environment] = 300,
|
||||
[ScopeType.Component] = 400
|
||||
};
|
||||
|
||||
public ScopeAttachmentService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_attachments = new ConcurrentDictionary<string, ScopeAttachment>(StringComparer.OrdinalIgnoreCase);
|
||||
_scopeIndex = new ConcurrentDictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new scope attachment.
|
||||
/// </summary>
|
||||
/// <param name="request">The attachment request.</param>
|
||||
/// <param name="createdBy">Actor creating the attachment.</param>
|
||||
/// <returns>The created attachment.</returns>
|
||||
public ScopeAttachment Create(CreateScopeAttachmentRequest request, string? createdBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ScopeId) && request.ScopeType != ScopeType.Global)
|
||||
{
|
||||
throw new ArgumentException("ScopeId is required for non-global scope types.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
throw new ArgumentException("ProfileId is required.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var effectiveFrom = request.EffectiveFrom ?? now;
|
||||
var id = GenerateAttachmentId(request.ScopeType, request.ScopeId, request.ProfileId, effectiveFrom);
|
||||
|
||||
var attachment = new ScopeAttachment(
|
||||
Id: id,
|
||||
ScopeType: request.ScopeType,
|
||||
ScopeId: request.ScopeId ?? "*",
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion ?? "latest",
|
||||
Precedence: request.Precedence ?? ScopePrecedence[request.ScopeType],
|
||||
EffectiveFrom: effectiveFrom,
|
||||
EffectiveUntil: request.EffectiveUntil,
|
||||
CreatedAt: now,
|
||||
CreatedBy: createdBy,
|
||||
Metadata: request.Metadata);
|
||||
|
||||
_attachments[id] = attachment;
|
||||
IndexAttachment(attachment);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attachment by ID.
|
||||
/// </summary>
|
||||
public ScopeAttachment? Get(string attachmentId)
|
||||
{
|
||||
return _attachments.TryGetValue(attachmentId, out var attachment) ? attachment : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an attachment.
|
||||
/// </summary>
|
||||
public bool Delete(string attachmentId)
|
||||
{
|
||||
if (_attachments.TryRemove(attachmentId, out var attachment))
|
||||
{
|
||||
RemoveFromIndex(attachment);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries attachments based on criteria.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ScopeAttachment> Query(ScopeAttachmentQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
IEnumerable<ScopeAttachment> results = _attachments.Values;
|
||||
|
||||
if (query.ScopeType.HasValue)
|
||||
{
|
||||
results = results.Where(a => a.ScopeType == query.ScopeType.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ScopeId))
|
||||
{
|
||||
results = results.Where(a => a.ScopeId.Equals(query.ScopeId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ProfileId))
|
||||
{
|
||||
results = results.Where(a => a.ProfileId.Equals(query.ProfileId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!query.IncludeExpired)
|
||||
{
|
||||
results = results.Where(a => IsEffective(a, now));
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(a => a.Precedence)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.Take(query.Limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective profile for a given scope selector.
|
||||
/// Uses precedence rules: Component > Environment > Project > Organization > Global.
|
||||
/// </summary>
|
||||
/// <param name="selector">The scope selector to resolve.</param>
|
||||
/// <returns>Resolution result with the resolved profile and chain.</returns>
|
||||
public ScopeResolutionResult Resolve(ScopeSelector selector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var applicableAttachments = new List<ScopeAttachment>();
|
||||
ResolvedScopeProfile? resolved = null;
|
||||
|
||||
// Build scope hierarchy to check (most specific first)
|
||||
var scopesToCheck = new List<(ScopeType Type, string? Id)>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selector.ComponentPurl))
|
||||
{
|
||||
scopesToCheck.Add((ScopeType.Component, selector.ComponentPurl));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selector.Environment))
|
||||
{
|
||||
scopesToCheck.Add((ScopeType.Environment, selector.Environment));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selector.ProjectId))
|
||||
{
|
||||
scopesToCheck.Add((ScopeType.Project, selector.ProjectId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selector.OrganizationId))
|
||||
{
|
||||
scopesToCheck.Add((ScopeType.Organization, selector.OrganizationId));
|
||||
}
|
||||
|
||||
scopesToCheck.Add((ScopeType.Global, "*"));
|
||||
|
||||
// Find all applicable attachments
|
||||
foreach (var (scopeType, scopeId) in scopesToCheck)
|
||||
{
|
||||
var attachments = GetAttachmentsForScope(scopeType, scopeId ?? "*")
|
||||
.Where(a => IsEffective(a, now))
|
||||
.OrderByDescending(a => a.Precedence)
|
||||
.ThenByDescending(a => a.CreatedAt);
|
||||
|
||||
applicableAttachments.AddRange(attachments);
|
||||
}
|
||||
|
||||
// The highest precedence attachment wins
|
||||
var winning = applicableAttachments
|
||||
.OrderByDescending(a => a.Precedence)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (winning != null)
|
||||
{
|
||||
// Build inheritance chain from winning attachment down to global
|
||||
var chain = applicableAttachments
|
||||
.Where(a => a.Precedence <= winning.Precedence)
|
||||
.OrderByDescending(a => a.Precedence)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.DistinctBy(a => a.ScopeType)
|
||||
.ToList();
|
||||
|
||||
resolved = new ResolvedScopeProfile(
|
||||
ProfileId: winning.ProfileId,
|
||||
ProfileVersion: winning.ProfileVersion,
|
||||
ResolvedFrom: winning.ScopeType,
|
||||
ScopeId: winning.ScopeId,
|
||||
AttachmentId: winning.Id,
|
||||
InheritanceChain: chain.AsReadOnly());
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new ScopeResolutionResult(
|
||||
Selector: selector,
|
||||
ResolvedProfile: resolved,
|
||||
ApplicableAttachments: applicableAttachments.AsReadOnly(),
|
||||
ResolutionTimeMs: sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all attachments for a specific scope.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ScopeAttachment> GetAttachmentsForScope(ScopeType scopeType, string scopeId)
|
||||
{
|
||||
var key = BuildScopeKey(scopeType, scopeId);
|
||||
|
||||
if (_scopeIndex.TryGetValue(key, out var attachmentIds))
|
||||
{
|
||||
lock (attachmentIds)
|
||||
{
|
||||
return attachmentIds
|
||||
.Select(id => _attachments.TryGetValue(id, out var a) ? a : null)
|
||||
.Where(a => a != null)
|
||||
.Cast<ScopeAttachment>()
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<ScopeAttachment>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an attachment is currently effective.
|
||||
/// </summary>
|
||||
public bool IsEffective(ScopeAttachment attachment, DateTimeOffset? asOf = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
var now = asOf ?? _timeProvider.GetUtcNow();
|
||||
|
||||
if (now < attachment.EffectiveFrom)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attachment.EffectiveUntil.HasValue && now > attachment.EffectiveUntil.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an attachment's effective dates.
|
||||
/// </summary>
|
||||
public ScopeAttachment? UpdateEffectiveDates(
|
||||
string attachmentId,
|
||||
DateTimeOffset? effectiveFrom,
|
||||
DateTimeOffset? effectiveUntil,
|
||||
string? updatedBy = null)
|
||||
{
|
||||
if (!_attachments.TryGetValue(attachmentId, out var attachment))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var updated = attachment with
|
||||
{
|
||||
EffectiveFrom = effectiveFrom ?? attachment.EffectiveFrom,
|
||||
EffectiveUntil = effectiveUntil ?? attachment.EffectiveUntil
|
||||
};
|
||||
|
||||
_attachments[attachmentId] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expires an attachment immediately.
|
||||
/// </summary>
|
||||
public ScopeAttachment? Expire(string attachmentId, string? expiredBy = null)
|
||||
{
|
||||
return UpdateEffectiveDates(attachmentId, null, _timeProvider.GetUtcNow(), expiredBy);
|
||||
}
|
||||
|
||||
private void IndexAttachment(ScopeAttachment attachment)
|
||||
{
|
||||
var key = BuildScopeKey(attachment.ScopeType, attachment.ScopeId);
|
||||
var list = _scopeIndex.GetOrAdd(key, _ => new List<string>());
|
||||
|
||||
lock (list)
|
||||
{
|
||||
if (!list.Contains(attachment.Id))
|
||||
{
|
||||
list.Add(attachment.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveFromIndex(ScopeAttachment attachment)
|
||||
{
|
||||
var key = BuildScopeKey(attachment.ScopeType, attachment.ScopeId);
|
||||
|
||||
if (_scopeIndex.TryGetValue(key, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
list.Remove(attachment.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildScopeKey(ScopeType scopeType, string scopeId)
|
||||
{
|
||||
return $"{scopeType}:{scopeId}";
|
||||
}
|
||||
|
||||
private static string GenerateAttachmentId(ScopeType scopeType, string? scopeId, string profileId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{scopeType}|{scopeId ?? "*"}|{profileId}|{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"sa-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25519.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class RiskProfileValidator
|
||||
_schema = schema ?? throw new ArgumentNullException(nameof(schema));
|
||||
}
|
||||
|
||||
public ValidationResults Validate(string json)
|
||||
public EvaluationResults Validate(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
@@ -25,6 +25,6 @@ public sealed class RiskProfileValidator
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return _schema.Validate(document.RootElement);
|
||||
return _schema.Evaluate(document.RootElement);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user