save development progress
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Provcache;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to cache bypass settings from HTTP headers.
|
||||
/// </summary>
|
||||
public interface ICacheBypassAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether cache should be bypassed for the current request.
|
||||
/// </summary>
|
||||
bool ShouldBypassCache { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP header constants for cache control.
|
||||
/// </summary>
|
||||
public static class CacheBypassHeaders
|
||||
{
|
||||
/// <summary>
|
||||
/// Header to bypass cache entirely (forces re-evaluation).
|
||||
/// Value: "true" to bypass.
|
||||
/// </summary>
|
||||
public const string CacheBypass = "X-StellaOps-Cache-Bypass";
|
||||
|
||||
/// <summary>
|
||||
/// Header to force cache refresh (bypass read, but still write).
|
||||
/// Value: "true" to refresh.
|
||||
/// </summary>
|
||||
public const string CacheRefresh = "X-StellaOps-Cache-Refresh";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that reads from HttpContext headers.
|
||||
/// </summary>
|
||||
internal sealed class HttpCacheBypassAccessor : ICacheBypassAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ProvcacheOptions _options;
|
||||
|
||||
public HttpCacheBypassAccessor(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<ProvcacheOptions> options)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool ShouldBypassCache
|
||||
{
|
||||
get
|
||||
{
|
||||
// If bypass is disabled in options, never bypass
|
||||
if (!_options.AllowCacheBypass)
|
||||
return false;
|
||||
|
||||
var context = _httpContextAccessor.HttpContext;
|
||||
if (context?.Request.Headers is null)
|
||||
return false;
|
||||
|
||||
// Check for bypass header
|
||||
if (context.Request.Headers.TryGetValue(CacheBypassHeaders.CacheBypass, out var bypassValue))
|
||||
{
|
||||
return string.Equals(bypassValue.FirstOrDefault(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Check for refresh header (treats as bypass for read)
|
||||
if (context.Request.Headers.TryGetValue(CacheBypassHeaders.CacheRefresh, out var refreshValue))
|
||||
{
|
||||
return string.Equals(refreshValue.FirstOrDefault(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation for non-HTTP scenarios (e.g., background workers).
|
||||
/// </summary>
|
||||
internal sealed class NullCacheBypassAccessor : ICacheBypassAccessor
|
||||
{
|
||||
public static NullCacheBypassAccessor Instance { get; } = new();
|
||||
|
||||
public bool ShouldBypassCache => false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for cache bypass DI registration.
|
||||
/// </summary>
|
||||
public static class CacheBypassServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the cache bypass accessor to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCacheBypassAccessor(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<ICacheBypassAccessor, HttpCacheBypassAccessor>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Provcache;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation cache backed by the Provcache service.
|
||||
/// This implementation bridges the <see cref="IPolicyEvaluationCache"/> interface
|
||||
/// with the underlying <see cref="IProvcacheService"/> for high-density provenance caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The cache key mapping:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>PolicyDigest</c> → Provcache <c>PolicyHash</c></item>
|
||||
/// <item><c>SubjectDigest</c> → Provcache <c>SourceHash</c> (artifact)</item>
|
||||
/// <item><c>ContextDigest</c> → Combined from VeriKey context (sbom, vex, signer)</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class ProvcachePolicyEvaluationCache : IPolicyEvaluationCache
|
||||
{
|
||||
private readonly IProvcacheService _provcacheService;
|
||||
private readonly ProvcacheOptions _provcacheOptions;
|
||||
private readonly ICacheBypassAccessor _bypassAccessor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ProvcachePolicyEvaluationCache> _logger;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
|
||||
// Statistics
|
||||
private long _totalRequests;
|
||||
private long _cacheHits;
|
||||
private long _cacheMisses;
|
||||
private long _provcacheHits;
|
||||
private long _fallbacks;
|
||||
private long _bypassCount;
|
||||
|
||||
public ProvcachePolicyEvaluationCache(
|
||||
IProvcacheService provcacheService,
|
||||
ICacheBypassAccessor bypassAccessor,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ProvcacheOptions> provcacheOptions,
|
||||
IOptions<PolicyEngineOptions> policyOptions,
|
||||
ILogger<ProvcachePolicyEvaluationCache> logger)
|
||||
{
|
||||
_provcacheService = provcacheService ?? throw new ArgumentNullException(nameof(provcacheService));
|
||||
_bypassAccessor = bypassAccessor ?? throw new ArgumentNullException(nameof(bypassAccessor));
|
||||
_provcacheOptions = provcacheOptions?.Value ?? throw new ArgumentNullException(nameof(provcacheOptions));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var cacheSettings = policyOptions?.Value.EvaluationCache ?? new PolicyEvaluationCacheOptions();
|
||||
_defaultTtl = TimeSpan.FromMinutes(cacheSettings.DefaultTtlMinutes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyEvaluationCacheResult> GetAsync(
|
||||
PolicyEvaluationCacheKey key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _totalRequests);
|
||||
|
||||
// Check for cache bypass via HTTP header
|
||||
if (_bypassAccessor.ShouldBypassCache)
|
||||
{
|
||||
Interlocked.Increment(ref _bypassCount);
|
||||
_logger.LogDebug("Cache bypass requested via header for key: {Key}", key.ToCacheKey());
|
||||
return new PolicyEvaluationCacheResult(Entry: null, CacheHit: false, CacheSource.None);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var veriKey = BuildVeriKey(key);
|
||||
var result = await _provcacheService.GetAsync(veriKey, bypassCache: false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.WasCached && result.Entry is not null)
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
Interlocked.Increment(ref _provcacheHits);
|
||||
|
||||
var entry = ConvertToCacheEntry(result.Entry);
|
||||
return new PolicyEvaluationCacheResult(entry, CacheHit: true, CacheSource.Redis);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
return new PolicyEvaluationCacheResult(Entry: null, CacheHit: false, CacheSource.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Provcache lookup failed, treating as cache miss");
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
Interlocked.Increment(ref _fallbacks);
|
||||
return new PolicyEvaluationCacheResult(Entry: null, CacheHit: false, CacheSource.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyEvaluationCacheBatch> GetBatchAsync(
|
||||
IReadOnlyList<PolicyEvaluationCacheKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var found = new Dictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry>();
|
||||
var notFound = new List<PolicyEvaluationCacheKey>();
|
||||
int hits = 0, misses = 0, provcacheHits = 0;
|
||||
|
||||
// Provcache doesn't have native batch get, so we process individually
|
||||
// This could be optimized with a batch API in the future
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Entry is not null)
|
||||
{
|
||||
found[key] = result.Entry;
|
||||
hits++;
|
||||
if (result.Source == CacheSource.Redis)
|
||||
provcacheHits++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(key);
|
||||
misses++;
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyEvaluationCacheBatch
|
||||
{
|
||||
Found = found,
|
||||
NotFound = notFound,
|
||||
CacheHits = hits,
|
||||
CacheMisses = misses,
|
||||
RedisHits = provcacheHits,
|
||||
InMemoryHits = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(
|
||||
PolicyEvaluationCacheKey key,
|
||||
PolicyEvaluationCacheEntry entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var veriKey = BuildVeriKey(key);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = entry.ExpiresAt > now ? entry.ExpiresAt : now.Add(_defaultTtl);
|
||||
|
||||
var provcacheEntry = ConvertToProvcacheEntry(key, entry, veriKey, now, expiresAt);
|
||||
|
||||
try
|
||||
{
|
||||
await _provcacheService.SetAsync(provcacheEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored policy evaluation result in Provcache. VeriKey: {VeriKey}, ExpiresAt: {ExpiresAt}",
|
||||
veriKey, expiresAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to store policy evaluation in Provcache. VeriKey: {VeriKey}", veriKey);
|
||||
// Don't throw - cache failures shouldn't break evaluation flow
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetBatchAsync(
|
||||
IReadOnlyDictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Process individually since Provcache doesn't have batch set
|
||||
foreach (var (key, entry) in entries)
|
||||
{
|
||||
await SetAsync(key, entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InvalidateAsync(
|
||||
PolicyEvaluationCacheKey key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var veriKey = BuildVeriKey(key);
|
||||
await _provcacheService.InvalidateAsync(veriKey, reason: "policy-cache-invalidation", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Invalidated Provcache entry. VeriKey: {VeriKey}", veriKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InvalidateByPolicyDigestAsync(
|
||||
string policyDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new InvalidationRequest
|
||||
{
|
||||
Type = InvalidationType.PolicyHash,
|
||||
Value = policyDigest,
|
||||
Reason = "policy-update"
|
||||
};
|
||||
|
||||
var result = await _provcacheService.InvalidateByAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Invalidated {Count} Provcache entries for policy digest. PolicyDigest: {PolicyDigest}",
|
||||
result.EntriesAffected, policyDigest);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PolicyEvaluationCacheStats GetStats() => new()
|
||||
{
|
||||
TotalRequests = Interlocked.Read(ref _totalRequests),
|
||||
CacheHits = Interlocked.Read(ref _cacheHits),
|
||||
CacheMisses = Interlocked.Read(ref _cacheMisses),
|
||||
RedisHits = Interlocked.Read(ref _provcacheHits),
|
||||
InMemoryHits = 0,
|
||||
RedisFallbacks = Interlocked.Read(ref _fallbacks),
|
||||
ItemCount = 0, // Would require additional call to Provcache metrics
|
||||
EvictionCount = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a VeriKey from a policy evaluation cache key.
|
||||
/// </summary>
|
||||
private string BuildVeriKey(PolicyEvaluationCacheKey key)
|
||||
{
|
||||
// Build VeriKey from the three digests
|
||||
// PolicyDigest → policy_hash
|
||||
// SubjectDigest → source_hash (artifact)
|
||||
// ContextDigest → combines sbom, vex, signer context
|
||||
|
||||
var builder = new VeriKeyBuilder(_provcacheOptions)
|
||||
.WithSourceHash(key.SubjectDigest)
|
||||
.WithMergePolicyHash(key.PolicyDigest)
|
||||
// ContextDigest encapsulates sbom_hash, vex_hash_set, signer_set
|
||||
// We use it as a composite for the remaining VeriKey components
|
||||
.WithSbomHash(key.ContextDigest)
|
||||
// VEX hash set required for VeriKey - use ContextDigest as basis
|
||||
.WithVexHashSet(GetVexHashSetFromContext(key.ContextDigest))
|
||||
// Use default signer set and time window for now
|
||||
// In a full implementation, these would come from the evaluation context
|
||||
.WithSignerSetHash(GetDefaultSignerSetHash())
|
||||
.WithTimeWindow(_timeProvider.GetUtcNow());
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a VEX hash set identifier from the context digest.
|
||||
/// In a full implementation, this would be extracted from the evaluation context.
|
||||
/// </summary>
|
||||
private static string GetVexHashSetFromContext(string contextDigest)
|
||||
{
|
||||
// If context already contains a hash-like prefix, use it directly
|
||||
if (contextDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return contextDigest;
|
||||
}
|
||||
|
||||
// Otherwise, create a hash from the context
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"vex:{contextDigest}"));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Provcache entry to a policy evaluation cache entry.
|
||||
/// </summary>
|
||||
private PolicyEvaluationCacheEntry ConvertToCacheEntry(ProvcacheEntry entry)
|
||||
{
|
||||
// Extract evaluation result from the Provcache entry
|
||||
// The DecisionDigest contains the verdict and trust score
|
||||
var decision = entry.Decision;
|
||||
|
||||
// Map trust score to severity (higher trust = lower severity concern)
|
||||
var severity = decision.TrustScore >= 80 ? "info" :
|
||||
decision.TrustScore >= 60 ? "low" :
|
||||
decision.TrustScore >= 40 ? "medium" : "high";
|
||||
|
||||
return new PolicyEvaluationCacheEntry(
|
||||
Status: "evaluated",
|
||||
Severity: severity,
|
||||
RuleName: null,
|
||||
Priority: null,
|
||||
Annotations: ImmutableDictionary<string, string>.Empty
|
||||
.Add("trust_score", decision.TrustScore.ToString())
|
||||
.Add("verdict_hash", decision.VerdictHash)
|
||||
.Add("proof_root", decision.ProofRoot),
|
||||
Warnings: [],
|
||||
ExceptionId: null,
|
||||
CorrelationId: decision.VeriKey,
|
||||
EvaluatedAt: decision.CreatedAt,
|
||||
ExpiresAt: decision.ExpiresAt,
|
||||
Confidence: new ConfidenceScore
|
||||
{
|
||||
Value = (decimal)decision.TrustScore / 100m,
|
||||
Factors = [],
|
||||
Explanation = $"Trust score from Provcache: {decision.TrustScore}%"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a policy evaluation cache entry to a Provcache entry.
|
||||
/// </summary>
|
||||
private ProvcacheEntry ConvertToProvcacheEntry(
|
||||
PolicyEvaluationCacheKey key,
|
||||
PolicyEvaluationCacheEntry entry,
|
||||
string veriKey,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset expiresAt)
|
||||
{
|
||||
// Extract trust score from confidence if available
|
||||
var trustScore = entry.Confidence?.Value is decimal conf
|
||||
? (int)(conf * 100)
|
||||
: 50; // Default to medium trust
|
||||
|
||||
// Build verdict hash from status and severity
|
||||
var verdictHash = ComputeVerdictHash(entry);
|
||||
|
||||
var decision = new DecisionDigest
|
||||
{
|
||||
DigestVersion = "v1",
|
||||
VeriKey = veriKey,
|
||||
VerdictHash = verdictHash,
|
||||
ProofRoot = entry.Annotations.GetValueOrDefault("proof_root") ?? ComputeDefaultProofRoot(entry),
|
||||
ReplaySeed = new ReplaySeed
|
||||
{
|
||||
FeedIds = [],
|
||||
RuleIds = entry.RuleName is not null ? [entry.RuleName] : []
|
||||
},
|
||||
CreatedAt = entry.EvaluatedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
TrustScore = trustScore
|
||||
};
|
||||
|
||||
return new ProvcacheEntry
|
||||
{
|
||||
VeriKey = veriKey,
|
||||
Decision = decision,
|
||||
PolicyHash = key.PolicyDigest,
|
||||
SignerSetHash = GetDefaultSignerSetHash(),
|
||||
FeedEpoch = GetCurrentFeedEpoch(),
|
||||
CreatedAt = createdAt,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeVerdictHash(PolicyEvaluationCacheEntry entry)
|
||||
{
|
||||
// Create a deterministic hash of the evaluation result
|
||||
var content = $"{entry.Status}|{entry.Severity}|{entry.RuleName}|{entry.Priority}";
|
||||
return ComputeHash(content);
|
||||
}
|
||||
|
||||
private static string ComputeDefaultProofRoot(PolicyEvaluationCacheEntry entry)
|
||||
{
|
||||
// Generate a proof root from the entry content
|
||||
var content = $"{entry.CorrelationId}|{entry.EvaluatedAt:O}";
|
||||
return ComputeHash(content);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string GetDefaultSignerSetHash()
|
||||
{
|
||||
// Default signer set hash when not provided
|
||||
return "sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
||||
}
|
||||
|
||||
private string GetCurrentFeedEpoch()
|
||||
{
|
||||
// Use ISO week format for feed epoch
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var calendar = System.Globalization.CultureInfo.InvariantCulture.Calendar;
|
||||
var week = calendar.GetWeekOfYear(now.DateTime, System.Globalization.CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
|
||||
return $"{now.Year}-W{week:D2}";
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user