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>
|
||||
|
||||
Reference in New Issue
Block a user