feat(audit): wire AddAuditEmission into 9 services (AUDIT-002)
- 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>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering tenant-aware crypto provider resolution.
|
||||
/// </summary>
|
||||
public static class TenantAwareCryptoProviderRegistryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Decorates the existing <see cref="ICryptoProviderRegistry"/> with tenant-aware resolution.
|
||||
/// <para>
|
||||
/// When a tenant context is available and the tenant has configured crypto provider preferences,
|
||||
/// the decorated registry will prefer the tenant's chosen providers. Falls back to the default
|
||||
/// ordering when no tenant context exists or no preferences are set.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Prerequisites:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ICryptoProviderRegistry"/> must already be registered (via <c>AddStellaOpsCrypto</c>).</item>
|
||||
/// <item><see cref="ITenantCryptoPreferenceProvider"/> must be registered by the caller.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="tenantIdAccessorFactory">
|
||||
/// Factory that creates a function returning the current tenant ID (or null when no tenant context).
|
||||
/// Example: <c>sp => () => sp.GetService<IStellaOpsTenantAccessor>()?.TenantId</c>
|
||||
/// </param>
|
||||
/// <param name="cacheTtl">
|
||||
/// How long tenant preferences are cached before refresh. Default: 5 minutes.
|
||||
/// </param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddTenantAwareCryptoResolution(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, Func<string?>> tenantIdAccessorFactory,
|
||||
TimeSpan? cacheTtl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(tenantIdAccessorFactory);
|
||||
|
||||
// Manual decorator pattern: find the existing ICryptoProviderRegistry registration,
|
||||
// replace it with a factory that wraps the original in TenantAwareCryptoProviderRegistry.
|
||||
var innerDescriptor = services.LastOrDefault(d => d.ServiceType == typeof(ICryptoProviderRegistry));
|
||||
if (innerDescriptor is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ICryptoProviderRegistry is not registered. Call AddStellaOpsCrypto() before AddTenantAwareCryptoResolution().");
|
||||
}
|
||||
|
||||
services.Remove(innerDescriptor);
|
||||
|
||||
services.AddSingleton<ICryptoProviderRegistry>(sp =>
|
||||
{
|
||||
// Resolve the inner (original) registry
|
||||
var innerRegistry = ResolveInner(sp, innerDescriptor);
|
||||
|
||||
var preferenceProvider = sp.GetService<ITenantCryptoPreferenceProvider>();
|
||||
if (preferenceProvider is null)
|
||||
{
|
||||
// No preference provider registered; tenant-aware resolution is a no-op.
|
||||
// This is expected in CLI / background worker scenarios.
|
||||
return innerRegistry;
|
||||
}
|
||||
|
||||
var tenantIdAccessor = tenantIdAccessorFactory(sp);
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<TenantAwareCryptoProviderRegistry>();
|
||||
|
||||
return new TenantAwareCryptoProviderRegistry(
|
||||
innerRegistry,
|
||||
preferenceProvider,
|
||||
tenantIdAccessor,
|
||||
timeProvider,
|
||||
logger,
|
||||
cacheTtl);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static ICryptoProviderRegistry ResolveInner(IServiceProvider sp, ServiceDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.ImplementationInstance is ICryptoProviderRegistry instance)
|
||||
{
|
||||
return instance;
|
||||
}
|
||||
|
||||
if (descriptor.ImplementationFactory is not null)
|
||||
{
|
||||
return (ICryptoProviderRegistry)descriptor.ImplementationFactory(sp);
|
||||
}
|
||||
|
||||
if (descriptor.ImplementationType is not null)
|
||||
{
|
||||
return (ICryptoProviderRegistry)ActivatorUtilities.CreateInstance(sp, descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve inner ICryptoProviderRegistry from descriptor: {descriptor}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Provides per-tenant crypto provider ordering.
|
||||
/// Implementations are expected to cache results internally (recommended TTL: 60s-5min)
|
||||
/// to avoid hitting persistence on every crypto operation.
|
||||
/// </summary>
|
||||
public interface ITenantCryptoPreferenceProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the tenant's preferred provider ordering, or an empty list if no preferences are set.
|
||||
/// Only active preferences should be returned, ordered by priority (ascending).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier (normalised, lower-case).</param>
|
||||
/// <param name="algorithmScope">
|
||||
/// Algorithm scope filter (e.g., "SM", "GOST", or "*" for global).
|
||||
/// Implementations should return global ("*") preferences when no scope-specific preferences exist.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Ordered list of provider names; empty list means "use default ordering".</returns>
|
||||
Task<IReadOnlyList<string>> GetPreferredProvidersAsync(
|
||||
string tenantId,
|
||||
string algorithmScope = "*",
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user