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:
master
2026-04-08 16:20:39 +03:00
parent 65106afe4c
commit f5a9f874d0
34 changed files with 1865 additions and 24 deletions

View File

@@ -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&lt;IStellaOpsTenantAccessor&gt;()?.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}");
}
}

View File

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

View File

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