- Wire StellaOps.Audit.Emission DI in: Authority, Policy, Release-Orchestrator, EvidenceLocker, Notify, Scanner, Scheduler, Integrations, Platform - Add AuditEmission__TimelineBaseUrl to compose defaults - Endpoint filter annotation deferred to follow-up pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
219 lines
8.2 KiB
C#
219 lines
8.2 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace StellaOps.Cryptography;
|
|
|
|
/// <summary>
|
|
/// Decorator over <see cref="ICryptoProviderRegistry"/> that consults tenant preferences
|
|
/// (via <see cref="ITenantCryptoPreferenceProvider"/>) to override the default provider ordering.
|
|
/// <para>
|
|
/// Falls back to the inner registry's default ordering when:
|
|
/// <list type="bullet">
|
|
/// <item>No tenant context is available (CLI, background workers)</item>
|
|
/// <item>No preferences are configured for the current tenant</item>
|
|
/// <item>The preference provider is unavailable or throws</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class TenantAwareCryptoProviderRegistry : ICryptoProviderRegistry
|
|
{
|
|
private readonly ICryptoProviderRegistry inner;
|
|
private readonly ITenantCryptoPreferenceProvider preferenceProvider;
|
|
private readonly Func<string?> tenantIdAccessor;
|
|
private readonly TimeProvider timeProvider;
|
|
private readonly ILogger logger;
|
|
private readonly TimeSpan cacheTtl;
|
|
|
|
private readonly ConcurrentDictionary<string, CachedPreference> cache = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public TenantAwareCryptoProviderRegistry(
|
|
ICryptoProviderRegistry inner,
|
|
ITenantCryptoPreferenceProvider preferenceProvider,
|
|
Func<string?> tenantIdAccessor,
|
|
TimeProvider timeProvider,
|
|
ILogger logger,
|
|
TimeSpan? cacheTtl = null)
|
|
{
|
|
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
|
this.preferenceProvider = preferenceProvider ?? throw new ArgumentNullException(nameof(preferenceProvider));
|
|
this.tenantIdAccessor = tenantIdAccessor ?? throw new ArgumentNullException(nameof(tenantIdAccessor));
|
|
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
this.cacheTtl = cacheTtl ?? TimeSpan.FromMinutes(5);
|
|
}
|
|
|
|
public IReadOnlyCollection<ICryptoProvider> Providers => inner.Providers;
|
|
|
|
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
|
|
=> inner.TryResolve(preferredProvider, out provider);
|
|
|
|
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
|
|
{
|
|
var tenantOrder = GetTenantPreferredOrder(algorithmScope: "*");
|
|
if (tenantOrder is null || tenantOrder.Count == 0)
|
|
{
|
|
return inner.ResolveOrThrow(capability, algorithmId);
|
|
}
|
|
|
|
// Try tenant-preferred providers first
|
|
foreach (var providerName in tenantOrder)
|
|
{
|
|
if (inner.TryResolve(providerName, out var provider) &&
|
|
provider.Supports(capability, algorithmId))
|
|
{
|
|
CryptoProviderMetrics.RecordProviderResolution(provider.Name, capability, algorithmId);
|
|
return provider;
|
|
}
|
|
}
|
|
|
|
// Fall back to default ordering for providers not in the tenant preference list
|
|
return inner.ResolveOrThrow(capability, algorithmId);
|
|
}
|
|
|
|
public CryptoSignerResolution ResolveSigner(
|
|
CryptoCapability capability,
|
|
string algorithmId,
|
|
CryptoKeyReference keyReference,
|
|
string? preferredProvider = null)
|
|
{
|
|
// If caller already specified a preferred provider, honour it (explicit > tenant preference)
|
|
if (!string.IsNullOrWhiteSpace(preferredProvider))
|
|
{
|
|
return inner.ResolveSigner(capability, algorithmId, keyReference, preferredProvider);
|
|
}
|
|
|
|
var tenantPreferred = GetTenantPreferredProvider(capability, algorithmId);
|
|
return inner.ResolveSigner(capability, algorithmId, keyReference, tenantPreferred);
|
|
}
|
|
|
|
public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null)
|
|
{
|
|
// If caller already specified a preferred provider, honour it
|
|
if (!string.IsNullOrWhiteSpace(preferredProvider))
|
|
{
|
|
return inner.ResolveHasher(algorithmId, preferredProvider);
|
|
}
|
|
|
|
var tenantPreferred = GetTenantPreferredProvider(CryptoCapability.ContentHashing, algorithmId);
|
|
return inner.ResolveHasher(algorithmId, tenantPreferred);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the first tenant-preferred provider that supports the given capability and algorithm,
|
|
/// or null if no tenant preference applies.
|
|
/// </summary>
|
|
private string? GetTenantPreferredProvider(CryptoCapability capability, string algorithmId)
|
|
{
|
|
var tenantOrder = GetTenantPreferredOrder(algorithmScope: "*");
|
|
if (tenantOrder is null || tenantOrder.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (var providerName in tenantOrder)
|
|
{
|
|
if (inner.TryResolve(providerName, out var provider) &&
|
|
provider.Supports(capability, algorithmId))
|
|
{
|
|
return providerName;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the tenant's preferred provider order from cache, refreshing asynchronously when stale.
|
|
/// Returns null when no tenant context is available.
|
|
/// </summary>
|
|
private IReadOnlyList<string>? GetTenantPreferredOrder(string algorithmScope)
|
|
{
|
|
string? tenantId;
|
|
try
|
|
{
|
|
tenantId = tenantIdAccessor();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogDebug(ex, "Failed to resolve tenant ID for crypto provider selection; using default ordering.");
|
|
return null;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var cacheKey = $"{tenantId}:{algorithmScope}";
|
|
var now = timeProvider.GetUtcNow();
|
|
|
|
if (cache.TryGetValue(cacheKey, out var cached) && now - cached.FetchedAt < cacheTtl)
|
|
{
|
|
return cached.Providers;
|
|
}
|
|
|
|
// Cache miss or stale: refresh synchronously on first call, then async on subsequent stale reads.
|
|
// This avoids blocking on every request while still keeping the cache warm.
|
|
if (cached is not null)
|
|
{
|
|
// Stale: return stale data and refresh in background
|
|
_ = RefreshCacheAsync(cacheKey, tenantId, algorithmScope);
|
|
return cached.Providers;
|
|
}
|
|
|
|
// Cold miss: must block to get initial data
|
|
try
|
|
{
|
|
var providers = preferenceProvider.GetPreferredProvidersAsync(tenantId, algorithmScope, CancellationToken.None)
|
|
.ConfigureAwait(false)
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
|
|
var entry = new CachedPreference(providers, now);
|
|
cache[cacheKey] = entry;
|
|
|
|
if (providers.Count > 0)
|
|
{
|
|
logger.LogDebug(
|
|
"Loaded crypto provider preferences for tenant {TenantId} scope {Scope}: [{Providers}]",
|
|
tenantId, algorithmScope, string.Join(", ", providers));
|
|
}
|
|
|
|
return providers;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex,
|
|
"Failed to load crypto provider preferences for tenant {TenantId}; using default ordering.",
|
|
tenantId);
|
|
// Cache an empty result to avoid repeated failures
|
|
cache[cacheKey] = new CachedPreference(Array.Empty<string>(), now);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task RefreshCacheAsync(string cacheKey, string tenantId, string algorithmScope)
|
|
{
|
|
try
|
|
{
|
|
var providers = await preferenceProvider.GetPreferredProvidersAsync(
|
|
tenantId, algorithmScope, CancellationToken.None).ConfigureAwait(false);
|
|
|
|
cache[cacheKey] = new CachedPreference(providers, timeProvider.GetUtcNow());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex,
|
|
"Background refresh of crypto provider preferences failed for tenant {TenantId}.",
|
|
tenantId);
|
|
}
|
|
}
|
|
|
|
private sealed record CachedPreference(IReadOnlyList<string> Providers, DateTimeOffset FetchedAt);
|
|
}
|