save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -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;
}
}

View File

@@ -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}";
}
}

View File

@@ -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" />