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; /// /// Decorator over that consults tenant preferences /// (via ) to override the default provider ordering. /// /// Falls back to the inner registry's default ordering when: /// /// No tenant context is available (CLI, background workers) /// No preferences are configured for the current tenant /// The preference provider is unavailable or throws /// /// /// public sealed class TenantAwareCryptoProviderRegistry : ICryptoProviderRegistry { private readonly ICryptoProviderRegistry inner; private readonly ITenantCryptoPreferenceProvider preferenceProvider; private readonly Func tenantIdAccessor; private readonly TimeProvider timeProvider; private readonly ILogger logger; private readonly TimeSpan cacheTtl; private readonly ConcurrentDictionary cache = new(StringComparer.OrdinalIgnoreCase); public TenantAwareCryptoProviderRegistry( ICryptoProviderRegistry inner, ITenantCryptoPreferenceProvider preferenceProvider, Func 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 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); } /// /// Returns the first tenant-preferred provider that supports the given capability and algorithm, /// or null if no tenant preference applies. /// 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; } /// /// Reads the tenant's preferred provider order from cache, refreshing asynchronously when stale. /// Returns null when no tenant context is available. /// private IReadOnlyList? 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(), 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 Providers, DateTimeOffset FetchedAt); }