up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -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";

View File

@@ -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);

View File

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

View File

@@ -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)))
{

View File

@@ -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);

View File

@@ -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]}";
}
}

View File

@@ -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);

View File

@@ -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]}";
}
}

View File

@@ -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>

View File

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