up
This commit is contained in:
@@ -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]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user