This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,428 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Transport-agnostic effective decision map using StellaOps.Messaging abstractions.
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
/// </summary>
internal sealed class MessagingEffectiveDecisionMap : IEffectiveDecisionMap
{
private readonly IDistributedCache<string, EffectiveDecisionEntry> _entryCache;
private readonly ISortedIndex<string, string> _assetIndex;
private readonly IDistributedCache<string, long> _versionCache;
private readonly ILogger<MessagingEffectiveDecisionMap> _logger;
private readonly EffectiveDecisionMapOptions _options;
private readonly TimeProvider _timeProvider;
private const string EntryKeyPrefix = "edm:entry";
private const string IndexKeyPrefix = "edm:index";
private const string VersionKeyPrefix = "edm:version";
public MessagingEffectiveDecisionMap(
IDistributedCacheFactory cacheFactory,
ISortedIndexFactory sortedIndexFactory,
ILogger<MessagingEffectiveDecisionMap> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(cacheFactory);
ArgumentNullException.ThrowIfNull(sortedIndexFactory);
_entryCache = cacheFactory.Create<string, EffectiveDecisionEntry>(new CacheOptions { KeyPrefix = "edm:entries" });
_assetIndex = sortedIndexFactory.Create<string, string>("edm-asset-index");
_versionCache = cacheFactory.Create<string, long>(new CacheOptions { KeyPrefix = "edm:versions" });
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.EffectiveDecisionMap ?? new EffectiveDecisionMapOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task SetAsync(
string tenantId,
string snapshotId,
EffectiveDecisionEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
var now = _timeProvider.GetUtcNow();
var ttl = entry.ExpiresAt - now;
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
// Store entry with TTL
await _entryCache.SetAsync(entryKey, entry, cacheOptions, cancellationToken).ConfigureAwait(false);
// Add to sorted index by evaluated_at timestamp
var score = entry.EvaluatedAt.ToUnixTimeMilliseconds();
await _assetIndex.AddAsync(indexKey, entry.AssetId, score, cancellationToken).ConfigureAwait(false);
// Set TTL on index (slightly longer than entries)
await _assetIndex.SetExpirationAsync(indexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "set"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task SetBatchAsync(
string tenantId,
string snapshotId,
IEnumerable<EffectiveDecisionEntry> entries,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var indexKey = GetIndexKey(tenantId, snapshotId);
var count = 0;
var maxTtl = TimeSpan.Zero;
var indexElements = new List<ScoredElement<string>>();
foreach (var entry in entries)
{
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var ttl = entry.ExpiresAt - now;
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
if (ttl > maxTtl) maxTtl = ttl;
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
await _entryCache.SetAsync(entryKey, entry, cacheOptions, cancellationToken).ConfigureAwait(false);
indexElements.Add(new ScoredElement<string>(entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds()));
count++;
}
if (indexElements.Count > 0)
{
await _assetIndex.AddRangeAsync(indexKey, indexElements, cancellationToken).ConfigureAwait(false);
await _assetIndex.SetExpirationAsync(indexKey, maxTtl.Add(TimeSpan.FromMinutes(5)), cancellationToken).ConfigureAwait(false);
}
// Increment version after batch write
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count,
new KeyValuePair<string, object?>("operation", "set_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogDebug("Set {Count} effective decisions for snapshot {SnapshotId}", count, snapshotId);
}
public async Task<EffectiveDecisionEntry?> GetAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get"),
new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("cache_hit", result.HasValue));
return result.HasValue ? result.Value : null;
}
public async Task<EffectiveDecisionQueryResult> GetBatchAsync(
string tenantId,
string snapshotId,
IReadOnlyList<string> assetIds,
CancellationToken cancellationToken = default)
{
var entries = new Dictionary<string, EffectiveDecisionEntry>();
var notFound = new List<string>();
foreach (var assetId in assetIds)
{
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false);
if (result.HasValue && result.Value is not null)
{
entries[assetId] = result.Value;
}
else
{
notFound.Add(assetId);
}
}
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Count,
new KeyValuePair<string, object?>("operation", "get_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return new EffectiveDecisionQueryResult
{
Entries = entries,
NotFound = notFound,
MapVersion = version,
FromCache = true,
};
}
public async Task<IReadOnlyList<EffectiveDecisionEntry>> GetAllForSnapshotAsync(
string tenantId,
string snapshotId,
EffectiveDecisionFilter? filter = null,
CancellationToken cancellationToken = default)
{
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from index, ordered by score (evaluated_at) descending
var assetElements = await _assetIndex.GetByRankAsync(
indexKey, 0, -1, SortOrder.Descending, cancellationToken).ConfigureAwait(false);
if (assetElements.Count == 0)
{
return Array.Empty<EffectiveDecisionEntry>();
}
var entries = new List<EffectiveDecisionEntry>();
foreach (var element in assetElements)
{
var entryKey = GetEntryKey(tenantId, snapshotId, element.Element);
var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false);
if (!result.HasValue || result.Value is null) continue;
var entry = result.Value;
// Apply filters
if (filter != null)
{
if (filter.Statuses?.Count > 0 &&
!filter.Statuses.Contains(entry.Status, StringComparer.OrdinalIgnoreCase))
{
continue;
}
if (filter.Severities?.Count > 0 &&
(entry.Severity is null || !filter.Severities.Contains(entry.Severity, StringComparer.OrdinalIgnoreCase)))
{
continue;
}
if (filter.HasException == true && entry.ExceptionId is null)
{
continue;
}
if (filter.HasException == false && entry.ExceptionId is not null)
{
continue;
}
if (filter.MinAdvisoryCount.HasValue && entry.AdvisoryCount < filter.MinAdvisoryCount)
{
continue;
}
if (filter.MinHighSeverityCount.HasValue && entry.HighSeverityCount < filter.MinHighSeverityCount)
{
continue;
}
}
entries.Add(entry);
// Apply limit (accounting for offset)
if (filter?.Limit > 0 && entries.Count >= filter.Limit + (filter?.Offset ?? 0))
{
break;
}
}
// Apply offset
if (filter?.Offset > 0)
{
entries = entries.Skip(filter.Offset).ToList();
}
// Apply final limit
if (filter?.Limit > 0)
{
entries = entries.Take(filter.Limit).ToList();
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get_all"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return entries;
}
public async Task<EffectiveDecisionSummary> GetSummaryAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var entries = await GetAllForSnapshotAsync(tenantId, snapshotId, null, cancellationToken)
.ConfigureAwait(false);
var statusCounts = entries
.GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var severityCounts = entries
.Where(e => e.Severity is not null)
.GroupBy(e => e.Severity!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
return new EffectiveDecisionSummary
{
SnapshotId = snapshotId,
TotalAssets = entries.Count,
StatusCounts = statusCounts,
SeverityCounts = severityCounts,
ExceptionCount = entries.Count(e => e.ExceptionId is not null),
MapVersion = version,
ComputedAt = _timeProvider.GetUtcNow(),
};
}
public async Task InvalidateAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
await _entryCache.InvalidateAsync(entryKey, cancellationToken).ConfigureAwait(false);
await _assetIndex.RemoveAsync(indexKey, assetId, cancellationToken).ConfigureAwait(false);
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "invalidate"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task InvalidateSnapshotAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from the index
var assetElements = await _assetIndex.GetByRankAsync(indexKey, 0, -1, cancellationToken: cancellationToken).ConfigureAwait(false);
var count = assetElements.Count;
foreach (var element in assetElements)
{
var entryKey = GetEntryKey(tenantId, snapshotId, element.Element);
await _entryCache.InvalidateAsync(entryKey, cancellationToken).ConfigureAwait(false);
}
// Delete the index
await _assetIndex.DeleteAsync(indexKey, cancellationToken).ConfigureAwait(false);
// Delete the version
var versionKey = GetVersionKey(tenantId, snapshotId);
await _versionCache.InvalidateAsync(versionKey, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count,
new KeyValuePair<string, object?>("operation", "invalidate_snapshot"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} entries for snapshot {SnapshotId}", count, snapshotId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
// Invalidate all entries and indexes for the tenant using pattern matching
var pattern = $"{EntryKeyPrefix}:{tenantId}:*";
var entryCount = await _entryCache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false);
// Note: ISortedIndex doesn't have pattern-based deletion, so we can't easily clean up indexes
// This is a limitation of the abstraction - the Redis implementation handled this with KEYS scan
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(entryCount,
new KeyValuePair<string, object?>("operation", "invalidate_tenant"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} entries for tenant {TenantId}", entryCount, tenantId);
}
public async Task<long> GetVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var versionKey = GetVersionKey(tenantId, snapshotId);
var result = await _versionCache.GetAsync(versionKey, cancellationToken).ConfigureAwait(false);
return result.HasValue ? result.Value : 0;
}
public async Task<long> IncrementVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var versionKey = GetVersionKey(tenantId, snapshotId);
var current = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
var newVersion = current + 1;
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10)
};
await _versionCache.SetAsync(versionKey, newVersion, cacheOptions, cancellationToken).ConfigureAwait(false);
return newVersion;
}
public Task<EffectiveDecisionMapStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
// Stats require implementation-specific queries that aren't available through abstractions
// Return placeholder stats - a complete implementation would need transport-specific code
return Task.FromResult(new EffectiveDecisionMapStats
{
TotalEntries = 0,
TotalSnapshots = 0,
MemoryUsedBytes = null,
ExpiringWithinHour = 0,
LastEvictionAt = null,
LastEvictionCount = 0,
});
}
private static string GetEntryKey(string tenantId, string snapshotId, string assetId) =>
$"{EntryKeyPrefix}:{tenantId}:{snapshotId}:{assetId}";
private static string GetIndexKey(string tenantId, string snapshotId) =>
$"{IndexKeyPrefix}:{tenantId}:{snapshotId}";
private static string GetVersionKey(string tenantId, string snapshotId) =>
$"{VersionKeyPrefix}:{tenantId}:{snapshotId}";
}

View File

@@ -0,0 +1,584 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Transport-agnostic exception effective cache using StellaOps.Messaging abstractions.
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
/// </summary>
internal sealed class MessagingExceptionEffectiveCache : IExceptionEffectiveCache
{
private readonly IDistributedCache<string, List<ExceptionCacheEntry>> _entryCache;
private readonly ISetStore<string, string> _exceptionIndex;
private readonly IDistributedCache<string, long> _versionCache;
private readonly IDistributedCache<string, Dictionary<string, string>> _statsCache;
private readonly IExceptionRepository _repository;
private readonly ILogger<MessagingExceptionEffectiveCache> _logger;
private readonly ExceptionCacheOptions _options;
private readonly TimeProvider _timeProvider;
private const string EntryKeyPrefix = "exc:entry";
private const string IndexKeyPrefix = "exc:index";
private const string VersionKeyPrefix = "exc:version";
private const string StatsKeyPrefix = "exc:stats";
public MessagingExceptionEffectiveCache(
IDistributedCacheFactory cacheFactory,
ISetStoreFactory setStoreFactory,
IExceptionRepository repository,
ILogger<MessagingExceptionEffectiveCache> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(cacheFactory);
ArgumentNullException.ThrowIfNull(setStoreFactory);
_entryCache = cacheFactory.Create<string, List<ExceptionCacheEntry>>(new CacheOptions { KeyPrefix = EntryKeyPrefix });
_exceptionIndex = setStoreFactory.Create<string, string>("exc-exception-index");
_versionCache = cacheFactory.Create<string, long>(new CacheOptions { KeyPrefix = VersionKeyPrefix });
_statsCache = cacheFactory.Create<string, Dictionary<string, string>>(new CacheOptions { KeyPrefix = StatsKeyPrefix });
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.ExceptionCache ?? new ExceptionCacheOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExceptionCacheQueryResult> GetForAssetAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var entries = new List<ExceptionCacheEntry>();
var fromCache = false;
// Try specific advisory key first
if (advisoryId is not null)
{
var specificKey = GetAssetKey(tenantId, assetId, advisoryId);
var specificResult = await _entryCache.GetAsync(specificKey, cancellationToken).ConfigureAwait(false);
if (specificResult.HasValue && specificResult.Value is not null)
{
entries.AddRange(specificResult.Value);
fromCache = true;
}
}
// Also get "all" entries (exceptions without specific advisory)
var allKey = GetAssetKey(tenantId, assetId, null);
var allResult = await _entryCache.GetAsync(allKey, cancellationToken).ConfigureAwait(false);
if (allResult.HasValue && allResult.Value is not null)
{
entries.AddRange(allResult.Value);
fromCache = true;
}
// Filter by time and sort by priority
var validEntries = entries
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
sw.Stop();
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, fromCache ? "hit" : "miss");
return new ExceptionCacheQueryResult
{
Entries = validEntries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = sw.ElapsedMilliseconds,
};
}
public async Task<IReadOnlyDictionary<string, ExceptionCacheQueryResult>> GetBatchAsync(
string tenantId,
IReadOnlyList<string> assetIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, ExceptionCacheQueryResult>(StringComparer.OrdinalIgnoreCase);
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
foreach (var assetId in assetIds)
{
var entries = ImmutableArray<ExceptionCacheEntry>.Empty;
var fromCache = false;
var allKey = GetAssetKey(tenantId, assetId, null);
var result = await _entryCache.GetAsync(allKey, cancellationToken).ConfigureAwait(false);
if (result.HasValue && result.Value is not null)
{
entries = result.Value
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
fromCache = true;
}
results[assetId] = new ExceptionCacheQueryResult
{
Entries = entries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = 0,
};
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "batch_get");
return results;
}
public async Task SetAsync(
string tenantId,
ExceptionCacheEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var assetKey = GetAssetKey(tenantId, entry.AssetId, entry.AdvisoryId);
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
// Get existing entries for this asset
var existingResult = await _entryCache.GetAsync(assetKey, cancellationToken).ConfigureAwait(false);
var entries = existingResult.HasValue && existingResult.Value is not null
? existingResult.Value
: new List<ExceptionCacheEntry>();
// Remove existing entry for same exception if any
entries.RemoveAll(e => e.ExceptionId == entry.ExceptionId);
// Add new entry
entries.Add(entry);
var ttl = ComputeTtl(entry);
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
// Store entry
await _entryCache.SetAsync(assetKey, entries, cacheOptions, cancellationToken).ConfigureAwait(false);
// Update exception index
await _exceptionIndex.AddAsync(exceptionIndexKey, assetKey, cancellationToken).ConfigureAwait(false);
await _exceptionIndex.SetExpirationAsync(exceptionIndexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken)
.ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set");
}
public async Task SetBatchAsync(
string tenantId,
IEnumerable<ExceptionCacheEntry> entries,
CancellationToken cancellationToken = default)
{
var count = 0;
// Group entries by asset+advisory
var groupedEntries = entries
.GroupBy(e => GetAssetKey(tenantId, e.AssetId, e.AdvisoryId))
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var (assetKey, assetEntries) in groupedEntries)
{
var ttl = assetEntries.Max(ComputeTtl);
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
await _entryCache.SetAsync(assetKey, assetEntries, cacheOptions, cancellationToken).ConfigureAwait(false);
// Update exception indexes
foreach (var entry in assetEntries)
{
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
await _exceptionIndex.AddAsync(exceptionIndexKey, assetKey, cancellationToken).ConfigureAwait(false);
await _exceptionIndex.SetExpirationAsync(exceptionIndexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken)
.ConfigureAwait(false);
}
count += assetEntries.Count;
}
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set_batch");
_logger.LogDebug("Set {Count} exception cache entries for tenant {TenantId}", count, tenantId);
}
public async Task InvalidateExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken = default)
{
var exceptionIndexKey = GetExceptionIndexKey(tenantId, exceptionId);
// Get all asset keys affected by this exception
var assetKeys = await _exceptionIndex.GetMembersAsync(exceptionIndexKey, cancellationToken).ConfigureAwait(false);
if (assetKeys.Count > 0)
{
// For each asset key, remove entries for this exception
foreach (var assetKey in assetKeys)
{
var result = await _entryCache.GetAsync(assetKey, cancellationToken).ConfigureAwait(false);
if (result.HasValue && result.Value is not null)
{
var entries = result.Value;
entries.RemoveAll(e => e.ExceptionId == exceptionId);
if (entries.Count > 0)
{
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes)
};
await _entryCache.SetAsync(assetKey, entries, cacheOptions, cancellationToken).ConfigureAwait(false);
}
else
{
await _entryCache.InvalidateAsync(assetKey, cancellationToken).ConfigureAwait(false);
}
}
}
}
// Delete the exception index
await _exceptionIndex.DeleteAsync(exceptionIndexKey, cancellationToken).ConfigureAwait(false);
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_exception");
_logger.LogInformation(
"Invalidated exception {ExceptionId} affecting {Count} assets for tenant {TenantId}",
exceptionId, assetKeys.Count, tenantId);
}
public async Task InvalidateAssetAsync(
string tenantId,
string assetId,
CancellationToken cancellationToken = default)
{
// Invalidate all keys for this asset using pattern
var pattern = $"{EntryKeyPrefix}:{tenantId}:{assetId}:*";
var count = await _entryCache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false);
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_asset");
_logger.LogDebug("Invalidated {Count} cache keys for asset {AssetId}", count, assetId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
// Invalidate all entry keys for tenant
var entryPattern = $"{EntryKeyPrefix}:{tenantId}:*";
var entryCount = await _entryCache.InvalidateByPatternAsync(entryPattern, cancellationToken).ConfigureAwait(false);
// Invalidate version and stats
var versionKey = GetVersionKey(tenantId);
await _versionCache.InvalidateAsync(versionKey, cancellationToken).ConfigureAwait(false);
var statsKey = GetStatsKey(tenantId);
await _statsCache.InvalidateAsync(statsKey, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_tenant");
_logger.LogInformation("Invalidated {Count} cache keys for tenant {TenantId}", entryCount, tenantId);
}
public async Task WarmAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.warm", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
var sw = Stopwatch.StartNew();
var now = _timeProvider.GetUtcNow();
_logger.LogInformation("Starting cache warm for tenant {TenantId}", tenantId);
try
{
var exceptions = await _repository.GetAllAsync(
tenantId,
ExceptionStatus.Active,
limit: _options.MaxEntriesPerTenant,
offset: 0,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (exceptions.Count == 0)
{
_logger.LogDebug("No active exceptions to warm for tenant {TenantId}", tenantId);
return;
}
var entries = new List<ExceptionCacheEntry>();
foreach (var exception in exceptions)
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id.ToString(),
AssetId = string.IsNullOrWhiteSpace(exception.ProjectId) ? "*" : exception.ProjectId!,
AdvisoryId = null,
CveId = null,
DecisionOverride = "allow",
ExceptionType = "waiver",
Priority = 0,
EffectiveFrom = exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
sw.Stop();
// Update warm stats
await UpdateWarmStatsAsync(tenantId, now, entries.Count, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "warm");
_logger.LogInformation(
"Warmed cache with {Count} entries from {ExceptionCount} exceptions for tenant {TenantId} in {Duration}ms",
entries.Count, exceptions.Count, tenantId, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to warm cache for tenant {TenantId}", tenantId);
PolicyEngineTelemetry.RecordError("exception_cache_warm", tenantId);
throw;
}
}
public async Task<ExceptionCacheSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
// Note: Full summary requires scanning keys which isn't efficient with abstractions
// Return placeholder data - complete implementation would need transport-specific code
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ExceptionCacheSummary
{
TenantId = tenantId,
TotalEntries = 0,
UniqueExceptions = 0,
UniqueAssets = 0,
ByType = new Dictionary<string, int>(),
ByDecision = new Dictionary<string, int>(),
ExpiringWithinHour = 0,
CacheVersion = version,
ComputedAt = now,
};
}
public Task<ExceptionCacheStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
// Stats require implementation-specific queries that aren't available through abstractions
// Return placeholder stats - a complete implementation would need transport-specific code
return Task.FromResult(new ExceptionCacheStats
{
TotalEntries = 0,
TotalTenants = 0,
MemoryUsedBytes = null,
HitCount = 0,
MissCount = 0,
LastWarmAt = null,
LastInvalidationAt = null,
});
}
public async Task<long> GetVersionAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var versionKey = GetVersionKey(tenantId);
var result = await _versionCache.GetAsync(versionKey, cancellationToken).ConfigureAwait(false);
return result.HasValue ? result.Value : 0;
}
public async Task HandleExceptionEventAsync(
ExceptionEvent exceptionEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(exceptionEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.handle_event", ActivityKind.Internal);
activity?.SetTag("tenant_id", exceptionEvent.TenantId);
activity?.SetTag("event_type", exceptionEvent.EventType);
activity?.SetTag("exception_id", exceptionEvent.ExceptionId);
_logger.LogDebug(
"Handling exception event {EventType} for exception {ExceptionId} tenant {TenantId}",
exceptionEvent.EventType, exceptionEvent.ExceptionId, exceptionEvent.TenantId);
switch (exceptionEvent.EventType.ToLowerInvariant())
{
case "activated":
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "expired":
case "revoked":
case "deleted":
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "updated":
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "created":
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
default:
_logger.LogWarning("Unknown exception event type: {EventType}", exceptionEvent.EventType);
break;
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(exceptionEvent.TenantId, $"event_{exceptionEvent.EventType}");
}
private async Task WarmExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
if (!Guid.TryParse(exceptionId, out var exceptionGuid))
{
_logger.LogWarning("Unable to parse exception id {ExceptionId} for tenant {TenantId}", exceptionId, tenantId);
return;
}
var exception = await _repository.GetByIdAsync(tenantId, exceptionGuid, cancellationToken)
.ConfigureAwait(false);
if (exception is null || exception.Status != ExceptionStatus.Active)
{
return;
}
var now = _timeProvider.GetUtcNow();
var entries = new List<ExceptionCacheEntry>
{
new ExceptionCacheEntry
{
ExceptionId = exception.Id.ToString(),
AssetId = string.IsNullOrWhiteSpace(exception.ProjectId) ? "*" : exception.ProjectId!,
AdvisoryId = null,
CveId = null,
DecisionOverride = "allow",
ExceptionType = "waiver",
Priority = 0,
EffectiveFrom = exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
}
};
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Warmed cache with {Count} entries for exception {ExceptionId}",
entries.Count, exceptionId);
}
private async Task<long> IncrementVersionAsync(string tenantId, CancellationToken cancellationToken)
{
var versionKey = GetVersionKey(tenantId);
var current = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
var newVersion = current + 1;
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10)
};
await _versionCache.SetAsync(versionKey, newVersion, cacheOptions, cancellationToken).ConfigureAwait(false);
return newVersion;
}
private async Task UpdateWarmStatsAsync(string tenantId, DateTimeOffset warmAt, int count, CancellationToken cancellationToken)
{
var statsKey = GetStatsKey(tenantId);
var stats = new Dictionary<string, string>
{
["lastWarmAt"] = warmAt.ToString("O"),
["lastWarmCount"] = count.ToString(),
};
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 30)
};
await _statsCache.SetAsync(statsKey, stats, cacheOptions, cancellationToken).ConfigureAwait(false);
}
private TimeSpan ComputeTtl(ExceptionCacheEntry entry)
{
if (entry.ExpiresAt.HasValue)
{
var ttl = entry.ExpiresAt.Value - _timeProvider.GetUtcNow();
if (ttl > TimeSpan.Zero)
{
return ttl;
}
}
return TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
private static string GetAssetKey(string tenantId, string assetId, string? advisoryId) =>
$"{tenantId}:{assetId}:{advisoryId ?? "all"}";
private static string GetExceptionIndexKey(string tenantId, string exceptionId) =>
$"{tenantId}:idx:{exceptionId}";
private static string GetVersionKey(string tenantId) =>
$"{tenantId}";
private static string GetStatsKey(string tenantId) =>
$"{tenantId}";
}

View File

@@ -0,0 +1,332 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Result of a policy gate evaluation.
/// </summary>
public sealed record PolicyGateDecision
{
/// <summary>
/// Unique identifier for this gate decision.
/// </summary>
[JsonPropertyName("gateId")]
public required string GateId { get; init; }
/// <summary>
/// The VEX status that was requested.
/// </summary>
[JsonPropertyName("requestedStatus")]
public required string RequestedStatus { get; init; }
/// <summary>
/// Subject of the decision (vuln, purl, symbol).
/// </summary>
[JsonPropertyName("subject")]
public required PolicyGateSubject Subject { get; init; }
/// <summary>
/// Evidence used in the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required PolicyGateEvidence Evidence { get; init; }
/// <summary>
/// Individual gate results.
/// </summary>
[JsonPropertyName("gates")]
public required ImmutableArray<PolicyGateResult> Gates { get; init; }
/// <summary>
/// Overall decision (allow, block, warn).
/// </summary>
[JsonPropertyName("decision")]
public required PolicyGateDecisionType Decision { get; init; }
/// <summary>
/// Advisory message if decision includes warnings.
/// </summary>
[JsonPropertyName("advisory")]
public string? Advisory { get; init; }
/// <summary>
/// Name of the gate that blocked, if blocked.
/// </summary>
[JsonPropertyName("blockedBy")]
public string? BlockedBy { get; init; }
/// <summary>
/// Reason for blocking.
/// </summary>
[JsonPropertyName("blockReason")]
public string? BlockReason { get; init; }
/// <summary>
/// Suggestion for resolving a block.
/// </summary>
[JsonPropertyName("suggestion")]
public string? Suggestion { get; init; }
/// <summary>
/// Timestamp when the decision was made.
/// </summary>
[JsonPropertyName("decidedAt")]
public required DateTimeOffset DecidedAt { get; init; }
}
/// <summary>
/// Subject of a policy gate decision.
/// </summary>
public sealed record PolicyGateSubject
{
/// <summary>
/// Vulnerability identifier.
/// </summary>
[JsonPropertyName("vulnId")]
public string? VulnId { get; init; }
/// <summary>
/// Package URL.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Symbol identifier.
/// </summary>
[JsonPropertyName("symbolId")]
public string? SymbolId { get; init; }
/// <summary>
/// Scan identifier.
/// </summary>
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
}
/// <summary>
/// Evidence used in a policy gate decision.
/// </summary>
public sealed record PolicyGateEvidence
{
/// <summary>
/// v1 lattice state code (U, SR, SU, RO, RU, CR, CU, X).
/// </summary>
[JsonPropertyName("latticeState")]
public string? LatticeState { get; init; }
/// <summary>
/// Uncertainty tier (T1, T2, T3, T4).
/// </summary>
[JsonPropertyName("uncertaintyTier")]
public string? UncertaintyTier { get; init; }
/// <summary>
/// BLAKE3 hash of the callgraph.
/// </summary>
[JsonPropertyName("graphHash")]
public string? GraphHash { get; init; }
/// <summary>
/// Risk score incorporating uncertainty.
/// </summary>
[JsonPropertyName("riskScore")]
public double? RiskScore { get; init; }
/// <summary>
/// Reachability confidence (0-1).
/// </summary>
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
/// <summary>
/// Whether runtime evidence exists.
/// </summary>
[JsonPropertyName("hasRuntimeEvidence")]
public bool HasRuntimeEvidence { get; init; }
/// <summary>
/// Path length from entry point to vulnerable symbol (-1 if unreachable).
/// </summary>
[JsonPropertyName("pathLength")]
public int? PathLength { get; init; }
}
/// <summary>
/// Result of a single gate evaluation.
/// </summary>
public sealed record PolicyGateResult
{
/// <summary>
/// Name of the gate.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Result of the gate evaluation.
/// </summary>
[JsonPropertyName("result")]
public required PolicyGateResultType Result { get; init; }
/// <summary>
/// Reason for the result.
/// </summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>
/// Additional note if result is pass_with_note.
/// </summary>
[JsonPropertyName("note")]
public string? Note { get; init; }
}
/// <summary>
/// Overall gate decision type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyGateDecisionType>))]
public enum PolicyGateDecisionType
{
/// <summary>
/// Status change is allowed.
/// </summary>
[JsonPropertyName("allow")]
Allow,
/// <summary>
/// Status change is blocked.
/// </summary>
[JsonPropertyName("block")]
Block,
/// <summary>
/// Status change is allowed with warning.
/// </summary>
[JsonPropertyName("warn")]
Warn
}
/// <summary>
/// Individual gate result type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyGateResultType>))]
public enum PolicyGateResultType
{
/// <summary>
/// Gate passed.
/// </summary>
[JsonPropertyName("pass")]
Pass,
/// <summary>
/// Gate passed with advisory note.
/// </summary>
[JsonPropertyName("pass_with_note")]
PassWithNote,
/// <summary>
/// Gate emitted a warning.
/// </summary>
[JsonPropertyName("warn")]
Warn,
/// <summary>
/// Gate blocked the request.
/// </summary>
[JsonPropertyName("block")]
Block,
/// <summary>
/// Gate was skipped (not applicable).
/// </summary>
[JsonPropertyName("skip")]
Skip
}
/// <summary>
/// Request to evaluate policy gates for a VEX status change.
/// </summary>
public sealed record PolicyGateRequest
{
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Vulnerability identifier.
/// </summary>
public string? VulnId { get; init; }
/// <summary>
/// Package URL.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// Symbol identifier.
/// </summary>
public string? SymbolId { get; init; }
/// <summary>
/// Scan identifier.
/// </summary>
public string? ScanId { get; init; }
/// <summary>
/// Requested VEX status (not_affected, affected, under_investigation, fixed).
/// </summary>
public required string RequestedStatus { get; init; }
/// <summary>
/// Justification for the status (required for some statuses).
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// v1 lattice state code.
/// </summary>
public string? LatticeState { get; init; }
/// <summary>
/// Uncertainty tier.
/// </summary>
public string? UncertaintyTier { get; init; }
/// <summary>
/// BLAKE3 graph hash.
/// </summary>
public string? GraphHash { get; init; }
/// <summary>
/// Risk score.
/// </summary>
public double? RiskScore { get; init; }
/// <summary>
/// Confidence score.
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Whether runtime evidence exists.
/// </summary>
public bool HasRuntimeEvidence { get; init; }
/// <summary>
/// Path length from entry point.
/// </summary>
public int? PathLength { get; init; }
/// <summary>
/// Whether to allow override (requires permission).
/// </summary>
public bool AllowOverride { get; init; }
/// <summary>
/// Override justification if AllowOverride is true.
/// </summary>
public string? OverrideJustification { get; init; }
}

View File

@@ -0,0 +1,746 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Evaluates policy gates for VEX status transitions.
/// Gates ensure that status changes are backed by sufficient evidence.
/// </summary>
public interface IPolicyGateEvaluator
{
/// <summary>
/// Evaluates all policy gates for a VEX status change request.
/// </summary>
/// <param name="request">The gate evaluation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The gate decision.</returns>
Task<PolicyGateDecision> EvaluateAsync(PolicyGateRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of <see cref="IPolicyGateEvaluator"/>.
/// </summary>
public sealed class PolicyGateEvaluator : IPolicyGateEvaluator
{
private readonly IOptionsMonitor<PolicyGateOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyGateEvaluator> _logger;
// VEX statuses
private const string StatusNotAffected = "not_affected";
private const string StatusAffected = "affected";
private const string StatusUnderInvestigation = "under_investigation";
private const string StatusFixed = "fixed";
// Lattice states (v1)
private const string LatticeUnknown = "U";
private const string LatticeStaticallyReachable = "SR";
private const string LatticeStaticallyUnreachable = "SU";
private const string LatticeRuntimeObserved = "RO";
private const string LatticeRuntimeUnobserved = "RU";
private const string LatticeConfirmedReachable = "CR";
private const string LatticeConfirmedUnreachable = "CU";
private const string LatticeContested = "X";
// Uncertainty tiers
private const string TierT1 = "T1";
private const string TierT2 = "T2";
private const string TierT3 = "T3";
private const string TierT4 = "T4";
public PolicyGateEvaluator(
IOptionsMonitor<PolicyGateOptions> options,
TimeProvider timeProvider,
ILogger<PolicyGateEvaluator> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public Task<PolicyGateDecision> EvaluateAsync(PolicyGateRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var options = _options.CurrentValue;
var now = _timeProvider.GetUtcNow();
// Build gate ID
var gateId = $"gate:vex:{request.RequestedStatus}:{now:O}";
// Build subject
var subject = new PolicyGateSubject
{
VulnId = request.VulnId,
Purl = request.Purl,
SymbolId = request.SymbolId,
ScanId = request.ScanId
};
// Build evidence
var evidence = new PolicyGateEvidence
{
LatticeState = request.LatticeState,
UncertaintyTier = request.UncertaintyTier,
GraphHash = request.GraphHash,
RiskScore = request.RiskScore,
Confidence = request.Confidence,
HasRuntimeEvidence = request.HasRuntimeEvidence,
PathLength = request.PathLength
};
// If gates are disabled, allow everything
if (!options.Enabled)
{
return Task.FromResult(CreateAllowDecision(gateId, request.RequestedStatus, subject, evidence, now, "Gates disabled"));
}
// Evaluate gates in order: Evidence -> Lattice -> Uncertainty -> Confidence
var gateResults = new List<PolicyGateResult>(4);
string? blockedBy = null;
string? blockReason = null;
string? suggestion = null;
var warnings = new List<string>();
// 1. Evidence Completeness Gate
var evidenceResult = EvaluateEvidenceCompletenessGate(request, options.EvidenceCompleteness);
gateResults.Add(evidenceResult);
if (evidenceResult.Result == PolicyGateResultType.Block)
{
blockedBy = evidenceResult.Name;
blockReason = evidenceResult.Reason;
suggestion = GetEvidenceSuggestion(request.RequestedStatus);
}
else if (evidenceResult.Result == PolicyGateResultType.Warn || evidenceResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(evidenceResult.Reason);
}
// 2. Lattice State Gate (only if not already blocked)
if (blockedBy is null)
{
var latticeResult = EvaluateLatticeStateGate(request, options.LatticeState);
gateResults.Add(latticeResult);
if (latticeResult.Result == PolicyGateResultType.Block)
{
blockedBy = latticeResult.Name;
blockReason = latticeResult.Reason;
suggestion = GetLatticeSuggestion(request.LatticeState, request.RequestedStatus);
}
else if (latticeResult.Result == PolicyGateResultType.Warn || latticeResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(latticeResult.Note ?? latticeResult.Reason);
}
}
// 3. Uncertainty Tier Gate (only if not already blocked)
if (blockedBy is null)
{
var uncertaintyResult = EvaluateUncertaintyTierGate(request, options.UncertaintyTier);
gateResults.Add(uncertaintyResult);
if (uncertaintyResult.Result == PolicyGateResultType.Block)
{
blockedBy = uncertaintyResult.Name;
blockReason = uncertaintyResult.Reason;
suggestion = GetUncertaintySuggestion(request.UncertaintyTier);
}
else if (uncertaintyResult.Result == PolicyGateResultType.Warn || uncertaintyResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(uncertaintyResult.Note ?? uncertaintyResult.Reason);
}
}
// 4. Confidence Threshold Gate (only if not already blocked)
if (blockedBy is null && request.Confidence.HasValue)
{
var confidenceResult = EvaluateConfidenceGate(request, options.EvidenceCompleteness);
gateResults.Add(confidenceResult);
if (confidenceResult.Result == PolicyGateResultType.Warn || confidenceResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(confidenceResult.Note ?? confidenceResult.Reason);
}
}
// Build final decision
PolicyGateDecisionType decision;
string? advisory = null;
if (blockedBy is not null)
{
// Check for override
if (request.AllowOverride && CanOverride(request, options.Override))
{
decision = PolicyGateDecisionType.Warn;
advisory = $"Override accepted: {request.OverrideJustification}";
_logger.LogInformation(
"Gate {Gate} overridden for {Status} on {Vuln}/{Purl}: {Justification}",
blockedBy, request.RequestedStatus, request.VulnId, request.Purl, request.OverrideJustification);
}
else
{
decision = PolicyGateDecisionType.Block;
_logger.LogInformation(
"Gate {Gate} blocked {Status} on {Vuln}/{Purl}: {Reason}",
blockedBy, request.RequestedStatus, request.VulnId, request.Purl, blockReason);
}
}
else if (warnings.Count > 0)
{
decision = PolicyGateDecisionType.Warn;
advisory = string.Join("; ", warnings);
}
else
{
decision = PolicyGateDecisionType.Allow;
}
var result = new PolicyGateDecision
{
GateId = gateId,
RequestedStatus = request.RequestedStatus,
Subject = subject,
Evidence = evidence,
Gates = gateResults.ToImmutableArray(),
Decision = decision,
Advisory = advisory,
BlockedBy = blockedBy,
BlockReason = blockReason,
Suggestion = suggestion,
DecidedAt = now
};
return Task.FromResult(result);
}
private PolicyGateResult EvaluateEvidenceCompletenessGate(PolicyGateRequest request, EvidenceCompletenessGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
switch (status)
{
case StatusNotAffected:
// Require graph hash
if (options.RequireGraphHashForNotAffected && string.IsNullOrWhiteSpace(request.GraphHash))
{
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Block,
Reason = "graphHash (DSSE-attested) is required for not_affected"
};
}
// Require path analysis
if (options.RequirePathAnalysisForNotAffected && request.PathLength is null)
{
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Block,
Reason = "pathAnalysis.pathLength is required for not_affected"
};
}
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Pass,
Reason = "Required evidence present for not_affected"
};
case StatusAffected:
if (options.WarnNoEvidenceForAffected &&
string.IsNullOrWhiteSpace(request.GraphHash) &&
!request.HasRuntimeEvidence)
{
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Warn,
Reason = "No graphHash or runtimeProbe evidence for affected status"
};
}
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Pass,
Reason = "Evidence present for affected"
};
case StatusUnderInvestigation:
case StatusFixed:
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Pass,
Reason = $"No evidence requirements for {status}"
};
default:
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Skip,
Reason = $"Unknown status: {status}"
};
}
}
private PolicyGateResult EvaluateLatticeStateGate(PolicyGateRequest request, LatticeStateGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
var latticeState = request.LatticeState?.ToUpperInvariant() ?? LatticeUnknown;
switch (status)
{
case StatusNotAffected:
return EvaluateLatticeForNotAffected(latticeState, request.Justification, options);
case StatusAffected:
return EvaluateLatticeForAffected(latticeState);
case StatusUnderInvestigation:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "Any lattice state allows under_investigation (safe default)"
};
case StatusFixed:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "Any lattice state allows fixed (remediation action)"
};
default:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Skip,
Reason = $"Unknown status: {status}"
};
}
}
private PolicyGateResult EvaluateLatticeForNotAffected(string latticeState, string? justification, LatticeStateGateOptions options)
{
switch (latticeState)
{
case LatticeConfirmedUnreachable:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "CU (ConfirmedUnreachable) allows not_affected"
};
case LatticeStaticallyUnreachable:
if (!options.AllowSUForNotAffected)
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "SU (StaticallyUnreachable) not allowed for not_affected (configuration)"
};
}
if (options.RequireJustificationForWeakStates && string.IsNullOrWhiteSpace(justification))
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "SU requires justification for not_affected"
};
}
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.PassWithNote,
Reason = "SU allows not_affected with warning",
Note = "Static analysis only; no runtime confirmation"
};
case LatticeRuntimeUnobserved:
if (!options.AllowRUForNotAffected)
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "RU (RuntimeUnobserved) not allowed for not_affected (configuration)"
};
}
if (options.RequireJustificationForWeakStates && string.IsNullOrWhiteSpace(justification))
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "RU requires justification for not_affected"
};
}
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.PassWithNote,
Reason = "RU allows not_affected with warning",
Note = "Runtime unobserved; may be reachable but untested code path"
};
case LatticeContested:
if (options.BlockContestedForDefinitiveStatuses)
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "X (Contested) incompatible with not_affected; conflicting static/runtime evidence"
};
}
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Warn,
Reason = "X (Contested) requires triage before not_affected"
};
case LatticeUnknown:
case LatticeStaticallyReachable:
case LatticeRuntimeObserved:
case LatticeConfirmedReachable:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = $"{latticeState} incompatible with not_affected"
};
default:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = $"Unknown lattice state {latticeState} cannot justify not_affected"
};
}
}
private PolicyGateResult EvaluateLatticeForAffected(string latticeState)
{
switch (latticeState)
{
case LatticeConfirmedReachable:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "CR (ConfirmedReachable) confirms affected"
};
case LatticeStaticallyReachable:
case LatticeRuntimeObserved:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = $"{latticeState} supports affected"
};
case LatticeUnknown:
case LatticeStaticallyUnreachable:
case LatticeRuntimeUnobserved:
case LatticeConfirmedUnreachable:
case LatticeContested:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Warn,
Reason = $"{latticeState} may indicate false positive for affected",
Note = "Consider review: evidence suggests code may not be reachable"
};
default:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "Unknown lattice state; allowing affected as safe default"
};
}
}
private PolicyGateResult EvaluateUncertaintyTierGate(PolicyGateRequest request, UncertaintyTierGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
var tier = request.UncertaintyTier?.ToUpperInvariant() ?? TierT4;
switch (status)
{
case StatusNotAffected:
return EvaluateUncertaintyForNotAffected(tier, options);
case StatusAffected:
return EvaluateUncertaintyForAffected(tier, options);
case StatusUnderInvestigation:
case StatusFixed:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = $"No uncertainty requirements for {status}"
};
default:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Skip,
Reason = $"Unknown status: {status}"
};
}
}
private PolicyGateResult EvaluateUncertaintyForNotAffected(string tier, UncertaintyTierGateOptions options)
{
switch (tier)
{
case TierT1:
if (options.BlockT1ForNotAffected)
{
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Block,
Reason = "T1 (High) uncertainty blocks not_affected; require human review"
};
}
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = "T1 (High) uncertainty; not_affected may be premature"
};
case TierT2:
if (options.WarnT2ForNotAffected)
{
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = "T2 (Medium) uncertainty; explicit override recommended",
Note = "Flag for review; decisions may need re-evaluation"
};
}
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "T2 (Medium) uncertainty allowed for not_affected"
};
case TierT3:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.PassWithNote,
Reason = "T3 (Low) uncertainty allows not_affected",
Note = "Advisory: Low uncertainty present"
};
case TierT4:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "T4 (Negligible) uncertainty; not_affected allowed"
};
default:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = $"Unknown uncertainty tier {tier}"
};
}
}
private PolicyGateResult EvaluateUncertaintyForAffected(string tier, UncertaintyTierGateOptions options)
{
switch (tier)
{
case TierT1:
if (options.ReviewT1ForAffected)
{
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = "T1 (High) uncertainty for affected; may be false positive",
Note = "Review required: high uncertainty suggests reachability analysis incomplete"
};
}
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "T1 (High) uncertainty; affected allowed as safe default"
};
case TierT2:
case TierT3:
case TierT4:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = $"{tier} uncertainty allows affected"
};
default:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "Unknown uncertainty tier; allowing affected as safe default"
};
}
}
private PolicyGateResult EvaluateConfidenceGate(PolicyGateRequest request, EvidenceCompletenessGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
var confidence = request.Confidence ?? 1.0;
if (status == StatusNotAffected)
{
if (confidence < options.MinConfidenceWarning)
{
return new PolicyGateResult
{
Name = "ConfidenceThreshold",
Result = PolicyGateResultType.Warn,
Reason = string.Create(CultureInfo.InvariantCulture, $"Confidence {confidence:P0} below warning threshold {options.MinConfidenceWarning:P0}"),
Note = "Low confidence in reachability analysis"
};
}
if (confidence < options.MinConfidenceForNotAffected)
{
return new PolicyGateResult
{
Name = "ConfidenceThreshold",
Result = PolicyGateResultType.Warn,
Reason = string.Create(CultureInfo.InvariantCulture, $"Confidence {confidence:P0} below recommended {options.MinConfidenceForNotAffected:P0}"),
Note = "Consider gathering additional evidence"
};
}
}
return new PolicyGateResult
{
Name = "ConfidenceThreshold",
Result = PolicyGateResultType.Pass,
Reason = string.Create(CultureInfo.InvariantCulture, $"Confidence {confidence:P0} meets requirements")
};
}
private static bool CanOverride(PolicyGateRequest request, OverrideOptions options)
{
if (!request.AllowOverride)
{
return false;
}
if (options.RequireJustification)
{
if (string.IsNullOrWhiteSpace(request.OverrideJustification))
{
return false;
}
if (request.OverrideJustification.Length < options.MinJustificationLength)
{
return false;
}
}
return true;
}
private static PolicyGateDecision CreateAllowDecision(
string gateId,
string requestedStatus,
PolicyGateSubject subject,
PolicyGateEvidence evidence,
DateTimeOffset decidedAt,
string reason)
{
return new PolicyGateDecision
{
GateId = gateId,
RequestedStatus = requestedStatus,
Subject = subject,
Evidence = evidence,
Gates = ImmutableArray.Create(new PolicyGateResult
{
Name = "Bypass",
Result = PolicyGateResultType.Pass,
Reason = reason
}),
Decision = PolicyGateDecisionType.Allow,
Advisory = reason,
DecidedAt = decidedAt
};
}
private static string GetEvidenceSuggestion(string status) => status switch
{
StatusNotAffected => "Submit DSSE-attested call graph with path analysis",
StatusAffected => "Consider providing graph hash or runtime probe evidence",
_ => "Provide additional evidence"
};
private static string GetLatticeSuggestion(string? latticeState, string status)
{
if (status == StatusNotAffected)
{
return latticeState switch
{
LatticeContested => "Resolve contested state through triage before claiming not_affected",
LatticeStaticallyReachable or LatticeRuntimeObserved or LatticeConfirmedReachable =>
"Submit runtime probe evidence showing unreachability or change to under_investigation",
LatticeUnknown => "Run reachability analysis to determine lattice state",
_ => "Provide evidence to support not_affected claim"
};
}
return "Review evidence and adjust status accordingly";
}
private static string GetUncertaintySuggestion(string? tier) => tier switch
{
TierT1 => "Reduce uncertainty: resolve missing symbol resolution, verify PURL mappings, or provide trusted advisory sources",
TierT2 => "Consider providing override with justification or reducing uncertainty through additional analysis",
_ => "Review uncertainty sources and address where possible"
};
}

View File

@@ -0,0 +1,136 @@
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Configuration options for policy gates.
/// </summary>
public sealed class PolicyGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "PolicyGates";
/// <summary>
/// Lattice state gate options.
/// </summary>
public LatticeStateGateOptions LatticeState { get; set; } = new();
/// <summary>
/// Uncertainty tier gate options.
/// </summary>
public UncertaintyTierGateOptions UncertaintyTier { get; set; } = new();
/// <summary>
/// Evidence completeness gate options.
/// </summary>
public EvidenceCompletenessGateOptions EvidenceCompleteness { get; set; } = new();
/// <summary>
/// Override mechanism options.
/// </summary>
public OverrideOptions Override { get; set; } = new();
/// <summary>
/// Whether gates are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}
/// <summary>
/// Configuration options for the lattice state gate.
/// </summary>
public sealed class LatticeStateGateOptions
{
/// <summary>
/// Allow StaticallyUnreachable (SU) state for not_affected with warning.
/// </summary>
public bool AllowSUForNotAffected { get; set; } = true;
/// <summary>
/// Allow RuntimeUnobserved (RU) state for not_affected with warning.
/// </summary>
public bool AllowRUForNotAffected { get; set; } = true;
/// <summary>
/// Require justification for weak states (SU, RU).
/// </summary>
public bool RequireJustificationForWeakStates { get; set; } = true;
/// <summary>
/// Block contested (X) state for definitive statuses.
/// </summary>
public bool BlockContestedForDefinitiveStatuses { get; set; } = true;
}
/// <summary>
/// Configuration options for the uncertainty tier gate.
/// </summary>
public sealed class UncertaintyTierGateOptions
{
/// <summary>
/// Block T1 (High) uncertainty for not_affected.
/// </summary>
public bool BlockT1ForNotAffected { get; set; } = true;
/// <summary>
/// Warn for T2 (Medium) uncertainty for not_affected.
/// </summary>
public bool WarnT2ForNotAffected { get; set; } = true;
/// <summary>
/// Require explicit override for T1 affected (possible false positive).
/// </summary>
public bool ReviewT1ForAffected { get; set; } = true;
}
/// <summary>
/// Configuration options for the evidence completeness gate.
/// </summary>
public sealed class EvidenceCompletenessGateOptions
{
/// <summary>
/// Require graph hash for not_affected.
/// </summary>
public bool RequireGraphHashForNotAffected { get; set; } = true;
/// <summary>
/// Minimum confidence threshold for not_affected.
/// </summary>
public double MinConfidenceForNotAffected { get; set; } = 0.8;
/// <summary>
/// Confidence threshold that triggers a warning.
/// </summary>
public double MinConfidenceWarning { get; set; } = 0.6;
/// <summary>
/// Require path analysis for not_affected.
/// </summary>
public bool RequirePathAnalysisForNotAffected { get; set; } = true;
/// <summary>
/// Warn if no graph hash or runtime probe for affected.
/// </summary>
public bool WarnNoEvidenceForAffected { get; set; } = true;
}
/// <summary>
/// Configuration options for override mechanism.
/// </summary>
public sealed class OverrideOptions
{
/// <summary>
/// Default expiration period for overrides in days.
/// </summary>
public int DefaultExpirationDays { get; set; } = 30;
/// <summary>
/// Require justification for all overrides.
/// </summary>
public bool RequireJustification { get; set; } = true;
/// <summary>
/// Minimum justification length.
/// </summary>
public int MinJustificationLength { get; set; } = 20;
}

View File

@@ -599,6 +599,8 @@ internal sealed class PolicyRuntimeEvaluationService
Method: fact.Method.ToString().ToLowerInvariant(),
EvidenceRef: fact.EvidenceRef ?? fact.EvidenceHash);
reachability = ApplyReachabilityEvidenceGate(reachability, fact.EvidenceRef);
ReachabilityFacts.ReachabilityFactsTelemetry.RecordFactApplied(reachability.State);
return request with { Reachability = reachability };
}
@@ -652,6 +654,8 @@ internal sealed class PolicyRuntimeEvaluationService
Method: fact.Method.ToString().ToLowerInvariant(),
EvidenceRef: fact.EvidenceRef ?? fact.EvidenceHash);
reachability = ApplyReachabilityEvidenceGate(reachability, fact.EvidenceRef);
ReachabilityFacts.ReachabilityFactsTelemetry.RecordFactApplied(reachability.State);
enriched.Add(request with { Reachability = reachability });
}
@@ -664,5 +668,21 @@ internal sealed class PolicyRuntimeEvaluationService
return enriched;
}
}
private static PolicyEvaluationReachability ApplyReachabilityEvidenceGate(
PolicyEvaluationReachability reachability,
string? evidenceRef)
{
if (!reachability.IsUnreachable)
{
return reachability;
}
if (!reachability.IsHighConfidence || string.IsNullOrWhiteSpace(evidenceRef))
{
return reachability with { State = "under_investigation" };
}
return reachability;
}
}

View File

@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />

View File

@@ -0,0 +1,7 @@
# Policy Engine · Local Tasks
This file mirrors sprint work for the Policy Engine module.
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DOING | Gate `unreachable` reachability facts: missing evidence ref or low confidence => `under_investigation`; add tests and docs. |

View File

@@ -43,7 +43,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normali
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "..\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{0A9EBE90-7C78-4A82-96A6-115E995AA816}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Mongo", "..\__Libraries\StellaOps.Provenance.Mongo\StellaOps.Provenance.Mongo.csproj", "{CD822B26-1E9B-4F85-BEC0-98B27883AC28}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance", "..\__Libraries\StellaOps.Provenance\StellaOps.Provenance.csproj", "{CD822B26-1E9B-4F85-BEC0-98B27883AC28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{DF51ED55-0D85-4902-B45A-7103CF8AF692}"
EndProject

View File

@@ -51,7 +51,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normali
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "..\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{CD2E6593-79CC-4668-8CBD-EDF1A80DE0C6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Mongo", "..\__Libraries\StellaOps.Provenance.Mongo\StellaOps.Provenance.Mongo.csproj", "{F7DABB1F-2F0A-492B-A7D0-6AB0FED72D5B}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance", "..\__Libraries\StellaOps.Provenance\StellaOps.Provenance.csproj", "{F7DABB1F-2F0A-492B-A7D0-6AB0FED72D5B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{0482A07E-CDA3-4006-84E6-828B072995C2}"
EndProject

View File

@@ -0,0 +1,360 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Gates;
public class PolicyGateEvaluatorTests
{
private readonly PolicyGateEvaluator _evaluator;
private readonly PolicyGateOptions _options;
public PolicyGateEvaluatorTests()
{
_options = new PolicyGateOptions();
_evaluator = new PolicyGateEvaluator(
new OptionsMonitorWrapper(_options),
TimeProvider.System,
NullLogger<PolicyGateEvaluator>.Instance);
}
// Lattice State Gate Tests
[Fact]
public async Task NotAffected_WithCU_AllowsDecision()
{
var request = CreateRequest("not_affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
Assert.Null(decision.BlockedBy);
}
[Fact]
public async Task NotAffected_WithSU_AllowsWithWarning_WhenJustificationProvided()
{
var request = CreateRequest("not_affected", latticeState: "SU", justification: "Verified dead code");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.NotNull(decision.Advisory);
}
[Fact]
public async Task NotAffected_WithSU_Blocks_WhenNoJustification()
{
var request = CreateRequest("not_affected", latticeState: "SU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
}
[Fact]
public async Task NotAffected_WithSR_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "SR");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
Assert.Contains("SR", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithCR_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CR");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
}
[Fact]
public async Task NotAffected_WithContested_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "X");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("LatticeState", decision.BlockedBy);
Assert.Contains("Contested", decision.BlockReason);
}
[Fact]
public async Task Affected_WithCR_Allows()
{
var request = CreateRequest("affected", latticeState: "CR");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
[Fact]
public async Task Affected_WithCU_WarnsOfFalsePositive()
{
var request = CreateRequest("affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.Contains("false positive", decision.Advisory, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task UnderInvestigation_AllowsAnyLatticeState()
{
var states = new[] { "U", "SR", "SU", "RO", "RU", "CR", "CU", "X" };
foreach (var state in states)
{
var request = CreateRequest("under_investigation", latticeState: state);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
}
// Uncertainty Tier Gate Tests
[Fact]
public async Task NotAffected_WithT1_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T1");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("UncertaintyTier", decision.BlockedBy);
Assert.Contains("T1", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithT2_Warns()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T2");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.NotNull(decision.Advisory);
}
[Fact]
public async Task NotAffected_WithT3_AllowsWithNote()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T3");
var decision = await _evaluator.EvaluateAsync(request);
// T3 results in a PassWithNote which becomes a Warn decision
Assert.True(decision.Decision == PolicyGateDecisionType.Allow || decision.Decision == PolicyGateDecisionType.Warn);
}
[Fact]
public async Task NotAffected_WithT4_Allows()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
[Fact]
public async Task Affected_WithT1_WarnsOfReviewRequired()
{
var request = CreateRequest("affected", latticeState: "CR", uncertaintyTier: "T1");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.Contains("Review required", decision.Advisory, StringComparison.OrdinalIgnoreCase);
}
// Evidence Completeness Gate Tests
[Fact]
public async Task NotAffected_WithoutGraphHash_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4", graphHash: null);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("EvidenceCompleteness", decision.BlockedBy);
Assert.Contains("graphHash", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithoutPathLength_Blocks()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4", pathLength: null);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
Assert.Equal("EvidenceCompleteness", decision.BlockedBy);
Assert.Contains("pathLength", decision.BlockReason);
}
[Fact]
public async Task NotAffected_WithGraphHashAndPath_Allows()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4", graphHash: "blake3:abc", pathLength: -1);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
}
[Fact]
public async Task Affected_WithoutEvidence_Warns()
{
var request = CreateRequest("affected", latticeState: "CR", graphHash: null, hasRuntimeEvidence: false);
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
}
// Override Tests
[Fact]
public async Task Override_WithJustification_BypassesBlock()
{
var request = CreateRequest("not_affected", latticeState: "SR");
request = request with
{
AllowOverride = true,
OverrideJustification = "Manual review confirmed dead code path in production"
};
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Warn, decision.Decision);
Assert.Contains("Override accepted", decision.Advisory);
}
[Fact]
public async Task Override_WithoutJustification_DoesNotBypass()
{
var request = CreateRequest("not_affected", latticeState: "SR");
request = request with
{
AllowOverride = true,
OverrideJustification = "" // Empty justification
};
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
}
[Fact]
public async Task Override_WithShortJustification_DoesNotBypass()
{
var request = CreateRequest("not_affected", latticeState: "SR");
request = request with
{
AllowOverride = true,
OverrideJustification = "Too short" // Less than 20 characters
};
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Block, decision.Decision);
}
// Disabled Gates Tests
[Fact]
public async Task DisabledGates_AllowsEverything()
{
var options = new PolicyGateOptions { Enabled = false };
var evaluator = new PolicyGateEvaluator(
new OptionsMonitorWrapper(options),
TimeProvider.System,
NullLogger<PolicyGateEvaluator>.Instance);
var request = CreateRequest("not_affected", latticeState: "CR", uncertaintyTier: "T1");
var decision = await evaluator.EvaluateAsync(request);
Assert.Equal(PolicyGateDecisionType.Allow, decision.Decision);
Assert.Contains("disabled", decision.Advisory, StringComparison.OrdinalIgnoreCase);
}
// Decision Document Tests
[Fact]
public async Task Decision_ContainsGateId()
{
var request = CreateRequest("not_affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.NotNull(decision.GateId);
Assert.StartsWith("gate:vex:not_affected:", decision.GateId);
}
[Fact]
public async Task Decision_ContainsSubject()
{
var request = CreateRequest("not_affected", latticeState: "CU");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal("CVE-2025-12345", decision.Subject.VulnId);
Assert.Equal("pkg:maven/com.example/foo@1.0.0", decision.Subject.Purl);
}
[Fact]
public async Task Decision_ContainsEvidence()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
var decision = await _evaluator.EvaluateAsync(request);
Assert.Equal("CU", decision.Evidence.LatticeState);
Assert.Equal("T4", decision.Evidence.UncertaintyTier);
}
[Fact]
public async Task Decision_ContainsGateResults()
{
var request = CreateRequest("not_affected", latticeState: "CU", uncertaintyTier: "T4");
var decision = await _evaluator.EvaluateAsync(request);
Assert.NotEmpty(decision.Gates);
Assert.Contains(decision.Gates, g => g.Name == "EvidenceCompleteness");
Assert.Contains(decision.Gates, g => g.Name == "LatticeState");
Assert.Contains(decision.Gates, g => g.Name == "UncertaintyTier");
}
private static PolicyGateRequest CreateRequest(
string status,
string? latticeState = null,
string? uncertaintyTier = null,
string? graphHash = "blake3:abc123",
int? pathLength = -1,
bool hasRuntimeEvidence = false,
string? justification = null)
{
return new PolicyGateRequest
{
TenantId = "tenant-1",
VulnId = "CVE-2025-12345",
Purl = "pkg:maven/com.example/foo@1.0.0",
RequestedStatus = status,
LatticeState = latticeState,
UncertaintyTier = uncertaintyTier,
GraphHash = graphHash,
PathLength = pathLength,
HasRuntimeEvidence = hasRuntimeEvidence,
Justification = justification,
Confidence = 0.95,
RiskScore = 0.3
};
}
private sealed class OptionsMonitorWrapper : IOptionsMonitor<PolicyGateOptions>
{
private readonly PolicyGateOptions _options;
public OptionsMonitorWrapper(PolicyGateOptions options) => _options = options;
public PolicyGateOptions CurrentValue => _options;
public PolicyGateOptions Get(string? name) => _options;
public IDisposable? OnChange(Action<PolicyGateOptions, string?> listener) => null;
}
}

View File

@@ -231,6 +231,168 @@ public sealed class PolicyRuntimeEvaluationServiceTests
Assert.Equal("warn", response.Status);
}
[Fact]
public async Task EvaluateAsync_GatesUnreachableWithoutEvidenceRef_ToUnderInvestigation()
{
const string policy = """
policy "Reachability gate policy" syntax "stella-dsl@1" {
rule unreachable_to_not_affected priority 10 {
when reachability.state == "unreachable"
then status := "not_affected"
because "unreachable + evidence"
}
rule gated_to_under_investigation priority 20 {
when reachability.state == "under_investigation"
then status := "under_investigation"
because "unreachable but missing evidence"
}
rule default priority 100 {
when true
then status := "affected"
because "default"
}
}
""";
var harness = CreateHarness();
await harness.StoreTestPolicyAsync("pack-3", 1, policy);
var fact = new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:npm/lodash@4.17.21",
AdvisoryId = "CVE-2024-0001",
State = ReachabilityState.Unreachable,
Confidence = 0.92m,
Score = 0m,
HasRuntimeEvidence = false,
Source = "graph-analyzer",
Method = AnalysisMethod.Static,
EvidenceRef = null,
EvidenceHash = "sha256:deadbeef",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Metadata = new Dictionary<string, object?>()
};
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
var request = CreateRequest("pack-3", 1, severity: "Low");
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal("under_investigation", response.Status);
}
[Fact]
public async Task EvaluateAsync_GatesUnreachableWithLowConfidence_ToUnderInvestigation()
{
const string policy = """
policy "Reachability gate policy" syntax "stella-dsl@1" {
rule unreachable_to_not_affected priority 10 {
when reachability.state == "unreachable"
then status := "not_affected"
because "unreachable + evidence"
}
rule gated_to_under_investigation priority 20 {
when reachability.state == "under_investigation"
then status := "under_investigation"
because "unreachable but low confidence"
}
rule default priority 100 {
when true
then status := "affected"
because "default"
}
}
""";
var harness = CreateHarness();
await harness.StoreTestPolicyAsync("pack-4", 1, policy);
var fact = new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:npm/lodash@4.17.21",
AdvisoryId = "CVE-2024-0001",
State = ReachabilityState.Unreachable,
Confidence = 0.7m,
Score = 0m,
HasRuntimeEvidence = false,
Source = "graph-analyzer",
Method = AnalysisMethod.Static,
EvidenceRef = "cas://reachability/facts/fact-1",
EvidenceHash = "sha256:deadbeef",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Metadata = new Dictionary<string, object?>()
};
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
var request = CreateRequest("pack-4", 1, severity: "Low");
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal("under_investigation", response.Status);
}
[Fact]
public async Task EvaluateAsync_AllowsUnreachableWithEvidenceRefAndHighConfidence()
{
const string policy = """
policy "Reachability gate policy" syntax "stella-dsl@1" {
rule unreachable_to_not_affected priority 10 {
when reachability.state == "unreachable"
then status := "not_affected"
because "unreachable + evidence"
}
rule gated_to_under_investigation priority 20 {
when reachability.state == "under_investigation"
then status := "under_investigation"
because "gated"
}
rule default priority 100 {
when true
then status := "affected"
because "default"
}
}
""";
var harness = CreateHarness();
await harness.StoreTestPolicyAsync("pack-5", 1, policy);
var fact = new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:npm/lodash@4.17.21",
AdvisoryId = "CVE-2024-0001",
State = ReachabilityState.Unreachable,
Confidence = 0.92m,
Score = 0m,
HasRuntimeEvidence = false,
Source = "graph-analyzer",
Method = AnalysisMethod.Static,
EvidenceRef = "cas://reachability/facts/fact-1",
EvidenceHash = "sha256:deadbeef",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Metadata = new Dictionary<string, object?>()
};
await harness.ReachabilityStore.SaveAsync(fact, CancellationToken.None);
var request = CreateRequest("pack-5", 1, severity: "Low");
var response = await harness.Service.EvaluateAsync(request, CancellationToken.None);
Assert.Equal("not_affected", response.Status);
}
private static RuntimeEvaluationRequest CreateRequest(
string packId,
int version,

View File

@@ -1,13 +1,11 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Storage.InMemory;
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Workers;
@@ -15,74 +13,98 @@ namespace StellaOps.Policy.Engine.Tests.Workers;
public sealed class ExceptionLifecycleServiceTests
{
[Fact]
public async Task Activates_pending_exceptions_and_publishes_event()
public async Task Skips_processing_when_no_tenants_configured()
{
var time = new FakeTimeProvider(new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero));
var repo = new InMemoryExceptionRepository();
await repo.CreateExceptionAsync(new PolicyExceptionDocument
{
Id = "exc-1",
TenantId = "tenant-a",
Status = "approved",
Name = "Test exception",
EffectiveFrom = time.GetUtcNow().AddMinutes(-1),
}, CancellationToken.None);
var publisher = new RecordingPublisher();
var repository = new RecordingExceptionRepository();
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
var service = new ExceptionLifecycleService(
repo,
publisher,
repository,
options,
time,
NullLogger<ExceptionLifecycleService>.Instance);
await service.ProcessOnceAsync(CancellationToken.None);
var updated = await repo.GetExceptionAsync("tenant-a", "exc-1", CancellationToken.None);
updated!.Status.Should().Be("active");
publisher.Events.Should().ContainSingle(e => e.EventType == "activated" && e.ExceptionId == "exc-1");
repository.ExpiredTenants.Should().BeEmpty();
}
[Fact]
public async Task Expires_active_exceptions_and_publishes_event()
public async Task Expires_active_exceptions_for_configured_tenants()
{
var time = new FakeTimeProvider(new DateTimeOffset(2025, 12, 1, 12, 0, 0, TimeSpan.Zero));
var repo = new InMemoryExceptionRepository();
await repo.CreateExceptionAsync(new PolicyExceptionDocument
var id = Guid.Parse("8b0f1d8a-bcc8-4c11-a3db-2f1b10c31821");
await repo.CreateAsync(new ExceptionEntity
{
Id = "exc-2",
Id = id,
TenantId = "tenant-b",
Status = "active",
Status = ExceptionStatus.Active,
Name = "Expiring exception",
ExpiresAt = time.GetUtcNow().AddMinutes(-1),
Reason = "test-fixture",
ExpiresAt = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero),
CreatedAt = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero)
}, CancellationToken.None);
var publisher = new RecordingPublisher();
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
var configured = new PolicyEngineOptions();
configured.ResourceServer.RequiredTenants.Add("tenant-b");
var service = new ExceptionLifecycleService(
repo,
publisher,
options,
time,
Microsoft.Extensions.Options.Options.Create(configured),
NullLogger<ExceptionLifecycleService>.Instance);
await service.ProcessOnceAsync(CancellationToken.None);
var updated = await repo.GetExceptionAsync("tenant-b", "exc-2", CancellationToken.None);
updated!.Status.Should().Be("expired");
publisher.Events.Should().ContainSingle(e => e.EventType == "expired" && e.ExceptionId == "exc-2");
var updated = await repo.GetByIdAsync("tenant-b", id, CancellationToken.None);
updated!.Status.Should().Be(ExceptionStatus.Expired);
updated.RevokedAt.Should().NotBeNull();
}
private sealed class RecordingPublisher : IExceptionEventPublisher
private sealed class RecordingExceptionRepository : IExceptionRepository
{
public List<ExceptionEvent> Events { get; } = new();
public List<string> ExpiredTenants { get; } = new();
public Task PublishAsync(ExceptionEvent exceptionEvent, CancellationToken cancellationToken = default)
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
string tenantId,
ExceptionStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
string tenantId,
string ruleName,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
{
Events.Add(exceptionEvent);
return Task.CompletedTask;
ExpiredTenants.Add(tenantId);
return Task.FromResult(0);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}