Implement Exception Effect Registry and Evaluation Service
- Added IExceptionEffectRegistry interface and its implementation ExceptionEffectRegistry to manage exception effects based on type and reason. - Created ExceptionAwareEvaluationService for evaluating policies with automatic exception loading from the repository. - Developed unit tests for ExceptionAdapter and ExceptionEffectRegistry to ensure correct behavior and mappings of exceptions and effects. - Enhanced exception loading logic to filter expired and non-active exceptions, and to respect maximum exceptions limit. - Implemented caching mechanism in ExceptionAdapter to optimize repeated exception loading.
This commit is contained in:
302
src/Policy/StellaOps.Policy.Engine/Adapters/ExceptionAdapter.cs
Normal file
302
src/Policy/StellaOps.Policy.Engine/Adapters/ExceptionAdapter.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Options for exception adapter configuration.
|
||||
/// </summary>
|
||||
public sealed class ExceptionAdapterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cache TTL for loaded exceptions. Default: 60 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of exceptions to load per tenant. Default: 10000.
|
||||
/// </summary>
|
||||
public int MaxExceptionsPerTenant { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable caching. Default: true.
|
||||
/// </summary>
|
||||
public bool EnableCaching { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for adapting persisted exception objects to policy evaluation context.
|
||||
/// </summary>
|
||||
internal interface IExceptionAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads active exceptions for a tenant and converts them to PolicyEvaluationExceptions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="asOf">Point in time for expiry filtering (typically now).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Policy evaluation exceptions ready for use in evaluation context.</returns>
|
||||
Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cached exceptions for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
void InvalidateCache(Guid tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all cached exceptions.
|
||||
/// </summary>
|
||||
void InvalidateAllCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapts persisted ExceptionObject entities to PolicyEvaluationExceptions for policy evaluation.
|
||||
/// Includes caching layer for performance optimization.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionAdapter : IExceptionAdapter
|
||||
{
|
||||
private readonly IExceptionRepository _repository;
|
||||
private readonly IExceptionEffectRegistry _effectRegistry;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ExceptionAdapterOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExceptionAdapter> _logger;
|
||||
|
||||
private static readonly string CacheKeyPrefix = "exception_adapter:";
|
||||
|
||||
public ExceptionAdapter(
|
||||
IExceptionRepository repository,
|
||||
IExceptionEffectRegistry effectRegistry,
|
||||
IMemoryCache cache,
|
||||
IOptions<ExceptionAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExceptionAdapter> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_effectRegistry = effectRegistry ?? throw new ArgumentNullException(nameof(effectRegistry));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_options = options?.Value ?? new ExceptionAdapterOptions();
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildCacheKey(tenantId);
|
||||
|
||||
if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out PolicyEvaluationExceptions? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for tenant {TenantId} exceptions", tenantId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loading exceptions from repository for tenant {TenantId}", tenantId);
|
||||
|
||||
// Load active exceptions from repository
|
||||
var exceptions = await LoadActiveExceptionsAsync(tenantId, asOf, cancellationToken);
|
||||
|
||||
// Convert to evaluation context format
|
||||
var result = ConvertToEvaluationExceptions(exceptions, asOf);
|
||||
|
||||
// Cache the result
|
||||
if (_options.EnableCaching)
|
||||
{
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.CacheTtl,
|
||||
Size = 1
|
||||
};
|
||||
_cache.Set(cacheKey, result, cacheOptions);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Loaded {Count} active exceptions for tenant {TenantId}",
|
||||
result.Instances.Length,
|
||||
tenantId);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateCache(Guid tenantId)
|
||||
{
|
||||
var cacheKey = BuildCacheKey(tenantId);
|
||||
_cache.Remove(cacheKey);
|
||||
_logger.LogDebug("Invalidated exception cache for tenant {TenantId}", tenantId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateAllCaches()
|
||||
{
|
||||
// IMemoryCache doesn't support enumeration, so we can't clear all entries.
|
||||
// In practice, callers should invalidate specific tenants or use a distributed cache
|
||||
// with proper invalidation patterns.
|
||||
_logger.LogWarning("InvalidateAllCaches called but IMemoryCache doesn't support enumeration. Consider using tenant-specific invalidation.");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ExceptionObject>> LoadActiveExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Create scope filter for active exceptions
|
||||
var scope = new ExceptionScope
|
||||
{
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
// Query repository for active exceptions not expired as of the given time
|
||||
var candidates = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
|
||||
|
||||
// Filter to only active status and not expired
|
||||
return candidates
|
||||
.Where(ex => ex.Status == ExceptionStatus.Active)
|
||||
.Where(ex => ex.ExpiresAt > asOf)
|
||||
.Take(_options.MaxExceptionsPerTenant)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private PolicyEvaluationExceptions ConvertToEvaluationExceptions(
|
||||
IReadOnlyList<ExceptionObject> exceptions,
|
||||
DateTimeOffset asOf)
|
||||
{
|
||||
if (exceptions.Count == 0)
|
||||
{
|
||||
return PolicyEvaluationExceptions.Empty;
|
||||
}
|
||||
|
||||
var effectsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyExceptionEffect>(StringComparer.OrdinalIgnoreCase);
|
||||
var instancesBuilder = ImmutableArray.CreateBuilder<PolicyEvaluationExceptionInstance>(exceptions.Count);
|
||||
|
||||
foreach (var exception in exceptions)
|
||||
{
|
||||
// Get or create effect for this exception type/reason
|
||||
var effect = _effectRegistry.GetEffect(exception.Type, exception.ReasonCode);
|
||||
var effectId = effect.Id;
|
||||
|
||||
// Add effect to dictionary (de-duplicate by ID)
|
||||
if (!effectsBuilder.ContainsKey(effectId))
|
||||
{
|
||||
effectsBuilder.Add(effectId, effect);
|
||||
}
|
||||
|
||||
// Create scope from exception scope
|
||||
var scope = ConvertScope(exception.Scope);
|
||||
|
||||
// Create instance
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: exception.ExceptionId,
|
||||
EffectId: effectId,
|
||||
Scope: scope,
|
||||
CreatedAt: exception.CreatedAt,
|
||||
Metadata: BuildMetadata(exception));
|
||||
|
||||
instancesBuilder.Add(instance);
|
||||
}
|
||||
|
||||
return new PolicyEvaluationExceptions(
|
||||
Effects: effectsBuilder.ToImmutable(),
|
||||
Instances: instancesBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
private static PolicyEvaluationExceptionScope ConvertScope(ExceptionScope scope)
|
||||
{
|
||||
// Map exception scope to evaluation scope
|
||||
// Policy rule IDs go to RuleNames
|
||||
// Vulnerability IDs go to Sources (advisory source matching)
|
||||
// PURL patterns go to Tags (for component matching)
|
||||
|
||||
var ruleNames = !string.IsNullOrEmpty(scope.PolicyRuleId)
|
||||
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.PolicyRuleId)
|
||||
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var sources = !string.IsNullOrEmpty(scope.VulnerabilityId)
|
||||
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.VulnerabilityId)
|
||||
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tags = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrEmpty(scope.PurlPattern))
|
||||
{
|
||||
// Use PURL pattern as a tag for component-based matching
|
||||
tags = tags.Add($"purl:{scope.PurlPattern}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
|
||||
{
|
||||
tags = tags.Add($"digest:{scope.ArtifactDigest}");
|
||||
}
|
||||
|
||||
// Environments are stored as tags with env: prefix
|
||||
foreach (var env in scope.Environments)
|
||||
{
|
||||
tags = tags.Add($"env:{env}");
|
||||
}
|
||||
|
||||
// Severities are not directly mapped from ExceptionScope
|
||||
// They would come from effect configuration
|
||||
var severities = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new PolicyEvaluationExceptionScope(
|
||||
RuleNames: ruleNames,
|
||||
Severities: severities,
|
||||
Sources: sources,
|
||||
Tags: tags);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildMetadata(ExceptionObject exception)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
builder["exception.type"] = exception.Type.ToString();
|
||||
builder["exception.reason"] = exception.ReasonCode.ToString();
|
||||
builder["exception.owner"] = exception.OwnerId;
|
||||
builder["exception.requester"] = exception.RequesterId;
|
||||
builder["exception.rationale"] = exception.Rationale;
|
||||
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O");
|
||||
|
||||
if (exception.ApproverIds.Length > 0)
|
||||
{
|
||||
builder["exception.approvers"] = string.Join(",", exception.ApproverIds);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(exception.TicketRef))
|
||||
{
|
||||
builder["exception.ticketRef"] = exception.TicketRef;
|
||||
}
|
||||
|
||||
if (exception.EvidenceRefs.Length > 0)
|
||||
{
|
||||
builder["exception.evidenceRefs"] = string.Join(",", exception.EvidenceRefs);
|
||||
}
|
||||
|
||||
if (exception.CompensatingControls.Length > 0)
|
||||
{
|
||||
builder["exception.compensatingControls"] = string.Join(",", exception.CompensatingControls);
|
||||
}
|
||||
|
||||
// Copy custom metadata
|
||||
foreach (var pair in exception.Metadata)
|
||||
{
|
||||
if (!builder.ContainsKey(pair.Key))
|
||||
{
|
||||
builder[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(Guid tenantId) => $"{CacheKeyPrefix}{tenantId:N}";
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Collections.Frozen;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for looking up exception effects based on type and reason.
|
||||
/// </summary>
|
||||
public interface IExceptionEffectRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the policy exception effect for a given exception type and reason.
|
||||
/// </summary>
|
||||
/// <param name="type">Exception type.</param>
|
||||
/// <param name="reason">Exception reason code.</param>
|
||||
/// <returns>The corresponding policy exception effect.</returns>
|
||||
PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered effects.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects();
|
||||
|
||||
/// <summary>
|
||||
/// Gets effect by ID.
|
||||
/// </summary>
|
||||
/// <param name="effectId">Effect identifier.</param>
|
||||
/// <returns>Effect if found, null otherwise.</returns>
|
||||
PolicyExceptionEffect? GetEffectById(string effectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry mapping exception type/reason combinations to policy exception effects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Effect mappings follow the auditable exception principles:
|
||||
/// - Suppress: Finding is suppressed from reports and gates
|
||||
/// - Defer: Finding is deferred (tracked but not blocking)
|
||||
/// - Downgrade: Finding severity is reduced
|
||||
/// - RequireControl: Finding requires compensating control verification
|
||||
/// </remarks>
|
||||
public sealed class ExceptionEffectRegistry : IExceptionEffectRegistry
|
||||
{
|
||||
private readonly FrozenDictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> _effectMap;
|
||||
private readonly FrozenDictionary<string, PolicyExceptionEffect> _effectsById;
|
||||
private readonly PolicyExceptionEffect _defaultEffect;
|
||||
|
||||
public ExceptionEffectRegistry()
|
||||
{
|
||||
var effects = BuildDefaultEffects();
|
||||
_effectMap = effects.ToFrozenDictionary();
|
||||
_effectsById = effects.Values
|
||||
.DistinctBy(e => e.Id)
|
||||
.ToFrozenDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
|
||||
_defaultEffect = CreateDefaultEffect();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason)
|
||||
{
|
||||
return _effectMap.TryGetValue((type, reason), out var effect)
|
||||
? effect
|
||||
: _defaultEffect;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects()
|
||||
{
|
||||
return _effectsById.Values;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PolicyExceptionEffect? GetEffectById(string effectId)
|
||||
{
|
||||
return _effectsById.TryGetValue(effectId, out var effect) ? effect : null;
|
||||
}
|
||||
|
||||
private static Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> BuildDefaultEffects()
|
||||
{
|
||||
// Define all effect templates
|
||||
var suppress = new PolicyExceptionEffect(
|
||||
Id: "suppress",
|
||||
Name: "Suppress Finding",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: 365,
|
||||
Description: "Suppresses the finding from reports and policy gates.");
|
||||
|
||||
var defer = new PolicyExceptionEffect(
|
||||
Id: "defer",
|
||||
Name: "Defer Finding",
|
||||
Effect: PolicyExceptionEffectType.Defer,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "deferred-review",
|
||||
MaxDurationDays: 90,
|
||||
Description: "Defers the finding for later review without blocking.");
|
||||
|
||||
var requireControl = new PolicyExceptionEffect(
|
||||
Id: "require-control",
|
||||
Name: "Require Compensating Control",
|
||||
Effect: PolicyExceptionEffectType.RequireControl,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: "compensating-control-verification",
|
||||
RoutingTemplate: "control-verification",
|
||||
MaxDurationDays: 180,
|
||||
Description: "Requires verification of compensating controls before allowing.");
|
||||
|
||||
var downgradeToLow = new PolicyExceptionEffect(
|
||||
Id: "downgrade-low",
|
||||
Name: "Downgrade to Low",
|
||||
Effect: PolicyExceptionEffectType.Downgrade,
|
||||
DowngradeSeverity: PolicySeverity.Low,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: 365,
|
||||
Description: "Downgrades finding severity to Low.");
|
||||
|
||||
var downgradeToMedium = new PolicyExceptionEffect(
|
||||
Id: "downgrade-medium",
|
||||
Name: "Downgrade to Medium",
|
||||
Effect: PolicyExceptionEffectType.Downgrade,
|
||||
DowngradeSeverity: PolicySeverity.Medium,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: 365,
|
||||
Description: "Downgrades finding severity to Medium.");
|
||||
|
||||
var deferVendor = new PolicyExceptionEffect(
|
||||
Id: "defer-vendor",
|
||||
Name: "Awaiting Vendor Fix",
|
||||
Effect: PolicyExceptionEffectType.Defer,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "vendor-tracking",
|
||||
MaxDurationDays: 180,
|
||||
Description: "Defers pending vendor patch release.");
|
||||
|
||||
var suppressDeprecation = new PolicyExceptionEffect(
|
||||
Id: "suppress-deprecation",
|
||||
Name: "Deprecation Waiver",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "deprecation-tracking",
|
||||
MaxDurationDays: 90,
|
||||
Description: "Temporary waiver during component deprecation.");
|
||||
|
||||
var suppressLicense = new PolicyExceptionEffect(
|
||||
Id: "suppress-license",
|
||||
Name: "License Waiver",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "legal-review",
|
||||
MaxDurationDays: 365,
|
||||
Description: "License compliance waiver after legal review.");
|
||||
|
||||
// Build the mapping
|
||||
return new Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect>
|
||||
{
|
||||
// Vulnerability exceptions
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.Other)] = defer,
|
||||
|
||||
// Policy exceptions
|
||||
[(ExceptionType.Policy, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Policy, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Policy, ExceptionReason.DeprecationInProgress)] = defer,
|
||||
[(ExceptionType.Policy, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Policy, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Policy, ExceptionReason.Other)] = defer,
|
||||
|
||||
// Unknown findings exceptions
|
||||
[(ExceptionType.Unknown, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Unknown, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Unknown, ExceptionReason.DeprecationInProgress)] = defer,
|
||||
[(ExceptionType.Unknown, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Unknown, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Unknown, ExceptionReason.Other)] = defer,
|
||||
|
||||
// Component exceptions
|
||||
[(ExceptionType.Component, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Component, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Component, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
|
||||
[(ExceptionType.Component, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Component, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Component, ExceptionReason.Other)] = suppressLicense,
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyExceptionEffect CreateDefaultEffect()
|
||||
{
|
||||
return new PolicyExceptionEffect(
|
||||
Id: "defer-default",
|
||||
Name: "Default Deferral",
|
||||
Effect: PolicyExceptionEffectType.Defer,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "manual-review",
|
||||
MaxDurationDays: 30,
|
||||
Description: "Default effect for unmapped exception type/reason combinations.");
|
||||
}
|
||||
}
|
||||
@@ -290,4 +290,32 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
services.Configure(configure);
|
||||
return services.AddPolicyEngine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds exception integration services for automatic exception loading during policy evaluation.
|
||||
/// Requires IExceptionRepository to be registered.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configure">Optional configuration for exception adapter options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPolicyExceptionIntegration(
|
||||
this IServiceCollection services,
|
||||
Action<Adapters.ExceptionAdapterOptions>? configure = null)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
// Register the effect registry (singleton, stateless)
|
||||
services.TryAddSingleton<Adapters.IExceptionEffectRegistry, Adapters.ExceptionEffectRegistry>();
|
||||
|
||||
// Register the exception adapter (singleton, uses IMemoryCache for caching)
|
||||
services.TryAddSingleton<Adapters.IExceptionAdapter, Adapters.ExceptionAdapter>();
|
||||
|
||||
// Register the exception-aware evaluation service
|
||||
services.TryAddSingleton<Services.IExceptionAwareEvaluationService, Services.ExceptionAwareEvaluationService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Request for exception-aware policy evaluation.
|
||||
/// Extends the base RuntimeEvaluationRequest with exception loading options.
|
||||
/// </summary>
|
||||
internal sealed record ExceptionAwareEvaluationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Base evaluation request.
|
||||
/// </summary>
|
||||
public required RuntimeEvaluationRequest BaseRequest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically load exceptions from the repository.
|
||||
/// If false, uses exceptions from BaseRequest.Exceptions (default behavior).
|
||||
/// If true, loads exceptions for the tenant and merges with any provided exceptions.
|
||||
/// </summary>
|
||||
public bool LoadExceptionsFromRepository { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for loading exceptions. Required if LoadExceptionsFromRepository is true.
|
||||
/// Falls back to parsing from TenantId in BaseRequest if not provided.
|
||||
/// </summary>
|
||||
public Guid? ExceptionTenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from exception-aware policy evaluation.
|
||||
/// </summary>
|
||||
internal sealed record ExceptionAwareEvaluationResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The underlying evaluation response.
|
||||
/// </summary>
|
||||
public required RuntimeEvaluationResponse Response { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of exceptions that were loaded from the repository.
|
||||
/// </summary>
|
||||
public int LoadedExceptionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether exceptions were loaded from the repository.
|
||||
/// </summary>
|
||||
public bool ExceptionsLoadedFromRepository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of exception loading in milliseconds.
|
||||
/// </summary>
|
||||
public long ExceptionLoadDurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for exception-aware policy evaluation.
|
||||
/// Automatically loads exceptions from the repository before evaluation.
|
||||
/// </summary>
|
||||
internal interface IExceptionAwareEvaluationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a policy with automatic exception loading.
|
||||
/// </summary>
|
||||
Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
|
||||
ExceptionAwareEvaluationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates multiple requests in batch with automatic exception loading.
|
||||
/// Exceptions are loaded once per tenant for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
|
||||
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception-aware policy evaluation service.
|
||||
/// Wraps PolicyRuntimeEvaluationService and automatically loads exceptions from the repository.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionAwareEvaluationService : IExceptionAwareEvaluationService
|
||||
{
|
||||
private readonly PolicyRuntimeEvaluationService _evaluator;
|
||||
private readonly IExceptionAdapter _exceptionAdapter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExceptionAwareEvaluationService> _logger;
|
||||
|
||||
public ExceptionAwareEvaluationService(
|
||||
PolicyRuntimeEvaluationService evaluator,
|
||||
IExceptionAdapter exceptionAdapter,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExceptionAwareEvaluationService> logger)
|
||||
{
|
||||
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
|
||||
_exceptionAdapter = exceptionAdapter ?? throw new ArgumentNullException(nameof(exceptionAdapter));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
|
||||
ExceptionAwareEvaluationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.BaseRequest);
|
||||
|
||||
var loadStartTimestamp = _timeProvider.GetTimestamp();
|
||||
var loadedCount = 0;
|
||||
var exceptionsLoaded = false;
|
||||
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
|
||||
|
||||
if (request.LoadExceptionsFromRepository)
|
||||
{
|
||||
var tenantId = ResolveTenantId(request);
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
var asOf = request.BaseRequest.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
|
||||
var loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
|
||||
tenantId.Value,
|
||||
asOf,
|
||||
cancellationToken);
|
||||
|
||||
// Merge loaded exceptions with any exceptions in the original request
|
||||
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
|
||||
loadedCount = loadedExceptions.Instances.Length;
|
||||
exceptionsLoaded = true;
|
||||
|
||||
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
|
||||
|
||||
_logger.LogDebug(
|
||||
"Loaded {Count} exceptions for tenant {TenantId} in evaluation request",
|
||||
loadedCount,
|
||||
tenantId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"LoadExceptionsFromRepository is true but no tenant ID available. " +
|
||||
"Falling back to exceptions in request.");
|
||||
}
|
||||
}
|
||||
|
||||
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
|
||||
|
||||
// Delegate to core evaluator
|
||||
var response = await _evaluator.EvaluateAsync(enrichedRequest, cancellationToken);
|
||||
|
||||
// Record telemetry for exception loading
|
||||
if (exceptionsLoaded && loadedCount > 0)
|
||||
{
|
||||
PolicyEngineTelemetry.RecordExceptionLoaded(
|
||||
enrichedRequest.TenantId,
|
||||
loadedCount,
|
||||
loadDuration / 1000.0);
|
||||
}
|
||||
|
||||
return new ExceptionAwareEvaluationResponse
|
||||
{
|
||||
Response = response,
|
||||
LoadedExceptionCount = loadedCount,
|
||||
ExceptionsLoadedFromRepository = exceptionsLoaded,
|
||||
ExceptionLoadDurationMs = loadDuration
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
|
||||
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
return Array.Empty<ExceptionAwareEvaluationResponse>();
|
||||
}
|
||||
|
||||
var loadStartTimestamp = _timeProvider.GetTimestamp();
|
||||
|
||||
// Group requests by tenant to load exceptions efficiently
|
||||
var tenantExceptionsCache = new Dictionary<Guid, PolicyEvaluationExceptions>();
|
||||
var enrichedRequests = new List<(ExceptionAwareEvaluationRequest Original, RuntimeEvaluationRequest Enriched, int LoadedCount)>();
|
||||
var asOf = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.BaseRequest);
|
||||
|
||||
var loadedCount = 0;
|
||||
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
|
||||
|
||||
if (request.LoadExceptionsFromRepository)
|
||||
{
|
||||
var tenantId = ResolveTenantId(request);
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
// Check cache first
|
||||
if (!tenantExceptionsCache.TryGetValue(tenantId.Value, out var loadedExceptions))
|
||||
{
|
||||
var requestAsOf = request.BaseRequest.EvaluationTimestamp ?? asOf;
|
||||
loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
|
||||
tenantId.Value,
|
||||
requestAsOf,
|
||||
cancellationToken);
|
||||
tenantExceptionsCache[tenantId.Value] = loadedExceptions;
|
||||
}
|
||||
|
||||
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
|
||||
loadedCount = loadedExceptions.Instances.Length;
|
||||
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
|
||||
}
|
||||
}
|
||||
|
||||
enrichedRequests.Add((request, enrichedRequest, loadedCount));
|
||||
}
|
||||
|
||||
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
|
||||
|
||||
// Evaluate all enriched requests
|
||||
var baseRequests = enrichedRequests.Select(e => e.Enriched).ToList();
|
||||
var responses = await _evaluator.EvaluateBatchAsync(baseRequests, cancellationToken);
|
||||
|
||||
// Build responses
|
||||
var results = new List<ExceptionAwareEvaluationResponse>(requests.Count);
|
||||
for (int i = 0; i < enrichedRequests.Count; i++)
|
||||
{
|
||||
var (original, _, loadedCount) = enrichedRequests[i];
|
||||
results.Add(new ExceptionAwareEvaluationResponse
|
||||
{
|
||||
Response = responses[i],
|
||||
LoadedExceptionCount = loadedCount,
|
||||
ExceptionsLoadedFromRepository = original.LoadExceptionsFromRepository,
|
||||
ExceptionLoadDurationMs = loadDuration / requests.Count // Amortized
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Batch evaluation with exception loading: {RequestCount} requests, {TenantCount} tenants, {TotalLoaded} total exceptions loaded",
|
||||
requests.Count,
|
||||
tenantExceptionsCache.Count,
|
||||
tenantExceptionsCache.Values.Sum(e => e.Instances.Length));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private Guid? ResolveTenantId(ExceptionAwareEvaluationRequest request)
|
||||
{
|
||||
// First try explicit exception tenant ID
|
||||
if (request.ExceptionTenantId.HasValue)
|
||||
{
|
||||
return request.ExceptionTenantId.Value;
|
||||
}
|
||||
|
||||
// Then try parsing from TenantId string in base request
|
||||
if (Guid.TryParse(request.BaseRequest.TenantId, out var parsedTenantId))
|
||||
{
|
||||
return parsedTenantId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PolicyEvaluationExceptions MergeExceptions(
|
||||
PolicyEvaluationExceptions original,
|
||||
PolicyEvaluationExceptions loaded)
|
||||
{
|
||||
if (original.IsEmpty)
|
||||
{
|
||||
return loaded;
|
||||
}
|
||||
|
||||
if (loaded.IsEmpty)
|
||||
{
|
||||
return original;
|
||||
}
|
||||
|
||||
// Merge effects (loaded takes precedence for same ID)
|
||||
var mergedEffects = original.Effects.ToBuilder();
|
||||
foreach (var effect in loaded.Effects)
|
||||
{
|
||||
mergedEffects[effect.Key] = effect.Value;
|
||||
}
|
||||
|
||||
// Merge instances (combine and de-duplicate by ID)
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var mergedInstances = new List<PolicyEvaluationExceptionInstance>();
|
||||
|
||||
// Add original instances first (these take precedence as they were explicitly provided)
|
||||
foreach (var instance in original.Instances)
|
||||
{
|
||||
if (seenIds.Add(instance.Id))
|
||||
{
|
||||
mergedInstances.Add(instance);
|
||||
}
|
||||
}
|
||||
|
||||
// Add loaded instances that don't conflict
|
||||
foreach (var instance in loaded.Instances)
|
||||
{
|
||||
if (seenIds.Add(instance.Id))
|
||||
{
|
||||
mergedInstances.Add(instance);
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyEvaluationExceptions(
|
||||
mergedEffects.ToImmutable(),
|
||||
mergedInstances.ToImmutableArray());
|
||||
}
|
||||
|
||||
private long GetElapsedMilliseconds(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
|
||||
return (long)elapsed.TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,20 @@ public static class PolicyEngineTelemetry
|
||||
unit: "events",
|
||||
description: "Lifecycle events for exceptions (activated, expired, revoked).");
|
||||
|
||||
// Counter: policy_exception_loaded_total{tenant}
|
||||
private static readonly Counter<long> ExceptionLoadedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_loaded_total",
|
||||
unit: "exceptions",
|
||||
description: "Total exceptions loaded from repository for evaluation.");
|
||||
|
||||
// Histogram: policy_exception_load_latency_seconds{tenant}
|
||||
private static readonly Histogram<double> ExceptionLoadLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_exception_load_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Latency of loading exceptions from repository.");
|
||||
|
||||
/// <summary>
|
||||
/// Counter for exception cache operations.
|
||||
/// </summary>
|
||||
@@ -879,6 +893,23 @@ public static class PolicyEngineTelemetry
|
||||
ExceptionLifecycleCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records exceptions loaded from repository for evaluation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="count">Number of exceptions loaded.</param>
|
||||
/// <param name="latencySeconds">Time taken to load exceptions in seconds.</param>
|
||||
public static void RecordExceptionLoaded(string tenant, int count, double latencySeconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
};
|
||||
|
||||
ExceptionLoadedCounter.Add(count, tags);
|
||||
ExceptionLoadLatencyHistogram.Record(latencySeconds, tags);
|
||||
}
|
||||
|
||||
#region Golden Signals - Recording Methods
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionAdapter.
|
||||
/// </summary>
|
||||
public sealed class ExceptionAdapterTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IExceptionRepository> _repositoryMock;
|
||||
private readonly IExceptionEffectRegistry _effectRegistry;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ExceptionAdapterOptions _options;
|
||||
private readonly ExceptionAdapter _adapter;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public ExceptionAdapterTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IExceptionRepository>();
|
||||
_effectRegistry = new ExceptionEffectRegistry();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_options = new ExceptionAdapterOptions
|
||||
{
|
||||
CacheTtl = TimeSpan.FromSeconds(60),
|
||||
EnableCaching = true,
|
||||
MaxExceptionsPerTenant = 10000
|
||||
};
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(_options),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_ReturnsEmpty_WhenNoExceptionsExist()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<ExceptionObject>());
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsEmpty.Should().BeTrue();
|
||||
result.Instances.Should().BeEmpty();
|
||||
result.Effects.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_FiltersExpiredExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
var expiredException = CreateException("EXC-002", ExceptionStatus.Active, now.AddDays(-1)); // Expired
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { activeException, expiredException });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Instances[0].Id.Should().Be("EXC-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_FiltersNonActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
var proposedException = CreateException("EXC-002", ExceptionStatus.Proposed, now.AddDays(30));
|
||||
var revokedException = CreateException("EXC-003", ExceptionStatus.Revoked, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { activeException, proposedException, revokedException });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Instances[0].Id.Should().Be("EXC-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_MapsExceptionTypeAndReasonToEffect()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
ExceptionType.Vulnerability,
|
||||
ExceptionReason.FalsePositive);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Effects.Should().ContainKey("suppress"); // FalsePositive maps to Suppress
|
||||
result.Instances[0].EffectId.Should().Be("suppress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_MapsScopeCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
scope: new ExceptionScope
|
||||
{
|
||||
PolicyRuleId = "block_critical",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PurlPattern = "pkg:npm/lodash@*",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
Environments = ["production", "staging"]
|
||||
});
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
var instance = result.Instances[0];
|
||||
|
||||
// Policy rule ID maps to RuleNames
|
||||
instance.Scope.RuleNames.Should().Contain("block_critical");
|
||||
|
||||
// Vulnerability ID maps to Sources
|
||||
instance.Scope.Sources.Should().Contain("CVE-2024-1234");
|
||||
|
||||
// PURL pattern maps to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("purl:pkg:npm/lodash@*");
|
||||
|
||||
// Artifact digest maps to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("digest:sha256:abc123");
|
||||
|
||||
// Environments map to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("env:production");
|
||||
instance.Scope.Tags.Should().Contain("env:staging");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_BuildsMetadataCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
ticketRef: "JIRA-1234",
|
||||
evidenceRefs: new[] { "sha256:evidence1", "sha256:evidence2" },
|
||||
compensatingControls: new[] { "WAF", "Rate-limiting" });
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
var instance = result.Instances[0];
|
||||
instance.Metadata.Should().ContainKey("exception.type");
|
||||
instance.Metadata.Should().ContainKey("exception.reason");
|
||||
instance.Metadata.Should().ContainKey("exception.owner");
|
||||
instance.Metadata.Should().ContainKey("exception.requester");
|
||||
instance.Metadata.Should().ContainKey("exception.rationale");
|
||||
instance.Metadata.Should().ContainKey("exception.ticketRef");
|
||||
instance.Metadata["exception.ticketRef"].Should().Be("JIRA-1234");
|
||||
instance.Metadata.Should().ContainKey("exception.evidenceRefs");
|
||||
instance.Metadata.Should().ContainKey("exception.compensatingControls");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_UsesCacheOnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act - First call
|
||||
var result1 = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Act - Second call (should hit cache)
|
||||
var result2 = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(result2);
|
||||
_repositoryMock.Verify(
|
||||
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once); // Should only call repository once
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_BypassesCache_WhenCachingDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var disabledCacheOptions = new ExceptionAdapterOptions { EnableCaching = false };
|
||||
var adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(disabledCacheOptions),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2)); // Should call repository twice
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateCache_RemovesCacheEntry()
|
||||
{
|
||||
// Arrange - pre-populate cache
|
||||
var cacheKey = $"exception_adapter:{_tenantId:N}";
|
||||
_cache.Set(cacheKey, PolicyEvaluationExceptions.Empty);
|
||||
|
||||
// Act
|
||||
_adapter.InvalidateCache(_tenantId);
|
||||
|
||||
// Assert
|
||||
_cache.TryGetValue(cacheKey, out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_RespectsMaxExceptionsLimit()
|
||||
{
|
||||
// Arrange
|
||||
var limitedOptions = new ExceptionAdapterOptions { MaxExceptionsPerTenant = 2 };
|
||||
var adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(limitedOptions),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exceptions = Enumerable.Range(1, 10)
|
||||
.Select(i => CreateException($"EXC-{i:000}", ExceptionStatus.Active, now.AddDays(30)))
|
||||
.ToArray();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exceptions);
|
||||
|
||||
// Act
|
||||
var result = await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
string exceptionId,
|
||||
ExceptionStatus status,
|
||||
DateTimeOffset expiresAt,
|
||||
ExceptionType type = ExceptionType.Vulnerability,
|
||||
ExceptionReason reason = ExceptionReason.AcceptedRisk,
|
||||
ExceptionScope? scope = null,
|
||||
string? ticketRef = null,
|
||||
string[]? evidenceRefs = null,
|
||||
string[]? compensatingControls = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Version = 1,
|
||||
Status = status,
|
||||
Type = type,
|
||||
Scope = scope ?? new ExceptionScope
|
||||
{
|
||||
TenantId = Guid.NewGuid()
|
||||
},
|
||||
OwnerId = "owner-001",
|
||||
RequesterId = "requester-001",
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = expiresAt,
|
||||
ReasonCode = reason,
|
||||
Rationale = "This is a test rationale that meets the minimum character requirement for exception objects.",
|
||||
TicketRef = ticketRef,
|
||||
EvidenceRefs = evidenceRefs?.ToImmutableArray() ?? [],
|
||||
CompensatingControls = compensatingControls?.ToImmutableArray() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionEffectRegistry.
|
||||
/// </summary>
|
||||
public sealed class ExceptionEffectRegistryTests
|
||||
{
|
||||
private readonly IExceptionEffectRegistry _registry;
|
||||
|
||||
public ExceptionEffectRegistryTests()
|
||||
{
|
||||
_registry = new ExceptionEffectRegistry();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl, PolicyExceptionEffectType.RequireControl)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.TestOnly, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation, PolicyExceptionEffectType.Downgrade)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation, PolicyExceptionEffectType.Downgrade)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForVulnerabilityType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.AcceptedRisk, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.CompensatingControl, PolicyExceptionEffectType.RequireControl)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForPolicyType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Unknown, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Unknown, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForUnknownType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Component, ExceptionReason.DeprecationInProgress, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Component, ExceptionReason.Other, PolicyExceptionEffectType.Suppress)] // License waiver
|
||||
public void GetEffect_ReturnsCorrectEffect_ForComponentType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffect_ReturnsDefaultDeferral_ForUnmappedCombination()
|
||||
{
|
||||
// Note: All combinations are mapped, so we test a hypothetical case
|
||||
// by checking that the registry handles all known combinations
|
||||
var allTypes = Enum.GetValues<ExceptionType>();
|
||||
var allReasons = Enum.GetValues<ExceptionReason>();
|
||||
|
||||
foreach (var type in allTypes)
|
||||
{
|
||||
foreach (var reason in allReasons)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert - should never be null
|
||||
effect.Should().NotBeNull();
|
||||
effect.Id.Should().NotBeNullOrEmpty();
|
||||
effect.Effect.Should().BeOneOf(
|
||||
PolicyExceptionEffectType.Suppress,
|
||||
PolicyExceptionEffectType.Defer,
|
||||
PolicyExceptionEffectType.Downgrade,
|
||||
PolicyExceptionEffectType.RequireControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllEffects_ReturnsDistinctEffects()
|
||||
{
|
||||
// Act
|
||||
var allEffects = _registry.GetAllEffects();
|
||||
|
||||
// Assert
|
||||
allEffects.Should().NotBeEmpty();
|
||||
allEffects.Should().OnlyHaveUniqueItems(e => e.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_ReturnsEffect_WhenExists()
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffectById("suppress");
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect!.Id.Should().Be("suppress");
|
||||
effect.Effect.Should().Be(PolicyExceptionEffectType.Suppress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffectById("non-existent-effect-id");
|
||||
|
||||
// Assert
|
||||
effect.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var effect1 = _registry.GetEffectById("SUPPRESS");
|
||||
var effect2 = _registry.GetEffectById("suppress");
|
||||
var effect3 = _registry.GetEffectById("Suppress");
|
||||
|
||||
// Assert
|
||||
effect1.Should().Be(effect2);
|
||||
effect2.Should().Be(effect3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effects_HaveValidProperties()
|
||||
{
|
||||
// Act
|
||||
var allEffects = _registry.GetAllEffects();
|
||||
|
||||
// Assert
|
||||
foreach (var effect in allEffects)
|
||||
{
|
||||
effect.Id.Should().NotBeNullOrWhiteSpace();
|
||||
effect.Name.Should().NotBeNullOrWhiteSpace();
|
||||
effect.Description.Should().NotBeNullOrWhiteSpace();
|
||||
effect.MaxDurationDays.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeEffects_HaveValidSeverity()
|
||||
{
|
||||
// Act
|
||||
var downgradeEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.Downgrade);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in downgradeEffects)
|
||||
{
|
||||
effect.DowngradeSeverity.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireControlEffects_HaveControlId()
|
||||
{
|
||||
// Act
|
||||
var requireControlEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.RequireControl);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in requireControlEffects)
|
||||
{
|
||||
effect.RequiredControlId.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuppressEffects_DoNotRequireControl()
|
||||
{
|
||||
// Act
|
||||
var suppressEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.Suppress);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in suppressEffects)
|
||||
{
|
||||
// Suppress effects should not require controls
|
||||
effect.RequiredControlId.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user