From ec0d8ad7352889556ae705f3ada8b5f2b5a108a7 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 5 Sep 2026 22:14:56 +0200 Subject: [PATCH] backend: blob proxy lifetime and timing, one delegation key per process, shared KEV instance, single-flight config misses (audit F11/F12) - DiagnosticsBlobStreamer disposes the download result on every exit path (it was disposed only on the 413 branch), measures open and transfer separately and fires DiagnosticsDownloadProxied after the copy with Completed=true/false. - UserDelegationKeyCache: one user delegation key per process (24 h, re-mint under a lock with 2 h margin or when a SAS would outlive it) instead of one storage round-trip per download or upload SAS. - KevDataService is resolved as one singleton over the typed-client handler pipeline; the cache warmer used to hydrate a throwaway transient instance while the correlation service held a cold one. - Admin and tenant configuration services single-flight concurrent cache misses (Lazy per key); the MemoryCache and the in-place projection semantics stay untouched. Co-Authored-By: Claude Fable 5.1 --- .../ConfigurationServiceSingleFlightTests.cs | 83 ++++++++++++ .../UserDelegationKeyCacheTests.cs | 88 +++++++++++++ .../AutopilotMonitor.Functions/Program.cs | 11 +- .../Services/AdminConfigurationService.cs | 19 +++ .../Services/BlobStorageService.cs | 7 +- .../Diagnostics/DiagnosticsBlobStreamer.cs | 120 ++++++++++-------- .../HostedDiagnosticsBlobService.cs | 7 +- .../Services/TenantConfigurationService.cs | 19 +++ .../Services/UserDelegationKeyCache.cs | 71 +++++++++++ 9 files changed, 370 insertions(+), 55 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs new file mode 100644 index 00000000..dcdedc30 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigurationServiceSingleFlightTests.cs @@ -0,0 +1,83 @@ +using System.Linq; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Config; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Concurrent cache misses on the two configuration services share one repository read +/// (audit 2026-09-05 F12). The MemoryCache stays: the hit path and the in-place projection +/// semantics are untouched, only the miss path is single-flighted. +/// +public class ConfigurationServiceSingleFlightTests +{ + [Fact] + public async Task Admin_config_concurrent_misses_issue_one_repository_read() + { + var gate = new TaskCompletionSource(); + var repo = new Mock(); + repo.Setup(r => r.GetAdminConfigurationAsync()).Returns(gate.Task); + var sut = new AdminConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + + var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync()).ToArray(); + gate.SetResult(AdminConfiguration.CreateDefault()); + var results = await Task.WhenAll(calls); + + repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Once); + Assert.All(results, r => Assert.Same(results[0], r)); + } + + [Fact] + public async Task Admin_config_after_completion_the_next_miss_reads_again() + { + var repo = new Mock(); + repo.Setup(r => r.GetAdminConfigurationAsync()).ReturnsAsync(AdminConfiguration.CreateDefault()); + var sut = new AdminConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + + await sut.GetConfigurationAsync(); + sut.InvalidateCache(); + await sut.GetConfigurationAsync(); + + repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Exactly(2)); + } + + [Fact] + public async Task Tenant_config_concurrent_misses_for_one_tenant_issue_one_repository_read() + { + const string tenantId = "11111111-1111-1111-1111-111111111111"; + var gate = new TaskCompletionSource(); + var repo = new Mock(); + repo.Setup(r => r.GetTenantConfigurationAsync(tenantId)).Returns(gate.Task); + var sut = new TenantConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + + var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync(tenantId)).ToArray(); + gate.SetResult(TenantConfiguration.CreateDefault(tenantId)); + var results = await Task.WhenAll(calls); + + repo.Verify(r => r.GetTenantConfigurationAsync(tenantId), Times.Once); + Assert.All(results, r => Assert.Same(results[0], r)); + } + + [Fact] + public async Task Tenant_config_different_tenants_do_not_share_a_flight() + { + const string a = "11111111-1111-1111-1111-111111111111"; + const string b = "22222222-2222-2222-2222-222222222222"; + var repo = new Mock(); + repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny())) + .Returns(id => Task.FromResult(TenantConfiguration.CreateDefault(id))); + var sut = new TenantConfigurationService(repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + + await Task.WhenAll(sut.GetConfigurationAsync(a), sut.GetConfigurationAsync(b)); + + repo.Verify(r => r.GetTenantConfigurationAsync(a), Times.Once); + repo.Verify(r => r.GetTenantConfigurationAsync(b), Times.Once); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs new file mode 100644 index 00000000..941cbfcb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UserDelegationKeyCacheTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using AutopilotMonitor.Functions.Services; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +public class UserDelegationKeyCacheTests +{ + private static (UserDelegationKeyCache cache, Mock client, Func now, Action advance) Build() + { + var clock = new DateTimeOffset(2026, 9, 5, 12, 0, 0, TimeSpan.Zero); + var client = new Mock(); + client + .Setup(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((start, expires, _) => + { + var key = BlobsModelFactory.UserDelegationKey("oid", "tid", start ?? clock, expires, "b", "2024-11-04", "c2lnbmVk"); + return Task.FromResult(Response.FromValue(key, Mock.Of())); + }); + var cache = new UserDelegationKeyCache(client.Object, () => clock); + return (cache, client, () => clock, delta => clock += delta); + } + + [Fact] + public async Task Second_sas_reuses_the_key_instead_of_minting_again() + { + var (cache, client, now, _) = Build(); + + var first = await cache.GetAsync(now() + TimeSpan.FromMinutes(15)); + var second = await cache.GetAsync(now() + TimeSpan.FromMinutes(60)); + + Assert.Same(first, second); + client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Key_is_minted_for_the_full_lifetime_with_clock_skew_on_the_start() + { + var (cache, _, now, _) = Build(); + + var key = await cache.GetAsync(now() + TimeSpan.FromMinutes(15)); + + Assert.Equal(now() - TimeSpan.FromMinutes(5), key.SignedStartsOn); + Assert.Equal(now() + UserDelegationKeyCache.KeyLifetime, key.SignedExpiresOn); + } + + [Fact] + public async Task Key_is_re_minted_once_the_refresh_margin_is_reached() + { + var (cache, client, now, advance) = Build(); + await cache.GetAsync(now() + TimeSpan.FromMinutes(15)); + + advance(UserDelegationKeyCache.KeyLifetime - UserDelegationKeyCache.RefreshMargin + TimeSpan.FromMinutes(1)); + await cache.GetAsync(now() + TimeSpan.FromMinutes(15)); + + client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task Key_is_re_minted_when_the_sas_would_outlive_it() + { + var (cache, client, now, _) = Build(); + await cache.GetAsync(now() + TimeSpan.FromMinutes(15)); + + await cache.GetAsync(now() + UserDelegationKeyCache.KeyLifetime + TimeSpan.FromMinutes(1)); + + client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task Concurrent_first_callers_share_one_mint() + { + var (cache, client, now, _) = Build(); + + var tasks = new Task[8]; + for (var i = 0; i < tasks.Length; i++) + tasks[i] = Task.Run(() => cache.GetAsync(now() + TimeSpan.FromMinutes(15))); + await Task.WhenAll(tasks); + + client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 6f72b971..752e0552 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -410,8 +410,17 @@ builder.Services.AddSingleton(sp => sp.GetRequiredS // Vulnerability correlation services builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); +// KevDataService keeps the hydrated catalog as instance state, but AddHttpClient registers T +// transient: the cache warmer hydrated one instance while the correlation service and the sync +// functions each held a cold one (audit 2026-09-05 F12). The typed-client registration stays for +// the named handler pipeline; the singleton below is what every consumer resolves. builder.Services.AddHttpClient() - .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); + .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); +builder.Services.AddSingleton(sp => new AutopilotMonitor.Functions.Services.Vulnerability.KevDataService( + sp.GetRequiredService().CreateClient(nameof(AutopilotMonitor.Functions.Services.Vulnerability.KevDataService)), + sp.GetRequiredService(), + sp.GetRequiredService>())); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddHttpClient() diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index d2b2a913..09bb1d22 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System; using System.Collections.Generic; using System.Linq; @@ -22,6 +23,10 @@ namespace AutopilotMonitor.Functions.Services private const string CacheKey = "admin-config"; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + // Concurrent misses (cold start, after invalidation) share one repository read instead of + // each issuing their own (audit 2026-09-05 F12). Lazy guarantees a single factory run. + private readonly ConcurrentDictionary>> _inFlight = new(); + public AdminConfigurationService(IConfigRepository configRepo, ILogger logger, IMemoryCache cache) { _configRepo = configRepo; @@ -43,6 +48,19 @@ namespace AutopilotMonitor.Functions.Services return cachedConfig; } + var load = _inFlight.GetOrAdd(CacheKey, _ => new Lazy>(LoadAndCacheAsync)); + try + { + return await load.Value; + } + finally + { + _inFlight.TryRemove(new KeyValuePair>>(CacheKey, load)); + } + } + + private async Task LoadAndCacheAsync() + { try { // Load from repository @@ -213,6 +231,7 @@ namespace AutopilotMonitor.Functions.Services public void InvalidateCache() { _cache.Remove(CacheKey); + _inFlight.TryRemove(CacheKey, out _); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs index 9d8c89f3..95766712 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs @@ -28,6 +28,7 @@ namespace AutopilotMonitor.Functions.Services private readonly BlobServiceClient _blobServiceClient; private readonly ILogger _logger; private readonly bool _usesManagedIdentity; + private readonly UserDelegationKeyCache _delegationKeys; public BlobStorageService(IConfiguration configuration, ILogger logger) { @@ -54,6 +55,8 @@ namespace AutopilotMonitor.Functions.Services throw new InvalidOperationException( "Blob Storage not configured. Set either 'AzureStorageAccountName' (for Managed Identity) or 'AzureBlobStorageConnectionString'."); } + + _delegationKeys = new UserDelegationKeyCache(_blobServiceClient); } /// @@ -67,6 +70,7 @@ namespace AutopilotMonitor.Functions.Services _blobServiceClient = blobServiceClient; _logger = logger; _usesManagedIdentity = usesManagedIdentity; + _delegationKeys = new UserDelegationKeyCache(_blobServiceClient); } /// @@ -121,8 +125,7 @@ namespace AutopilotMonitor.Functions.Services if (_usesManagedIdentity) { // Generate User Delegation SAS (no account key needed) - var delegationKey = await _blobServiceClient.GetUserDelegationKeyAsync( - DateTimeOffset.UtcNow.AddMinutes(-5), expiresOn); + var delegationKey = await _delegationKeys.GetAsync(expiresOn); var sasBuilder = new BlobSasBuilder { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs index 1132e49c..a6749ad1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobStreamer.cs @@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Functions.Diagnostics; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared; using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; using Microsoft.ApplicationInsights; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -81,15 +82,12 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics ? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)) : new CancellationTokenSource(); - var sw = Stopwatch.StartNew(); - long contentLength; - Stream content; + var openWatch = Stopwatch.StartNew(); + BlobDownloadStreamingResult download; if (destination == DiagnosticsDownloadFunction.BlobDestination.Hosted) { - var download = await _hostedDiagnostics.OpenReadAsync(blobName, cts.Token); - contentLength = download.Value.Details.ContentLength; - content = download.Value.Content; + download = (await _hostedDiagnostics.OpenReadAsync(blobName, cts.Token)).Value; } else { @@ -101,57 +99,79 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics var blobUrl = BuildCustomerBlobUrl(tenantConfig.DiagnosticsBlobSasUrl, blobName); var blobClient = new BlobClient(new Uri(blobUrl)); - var download = await blobClient.DownloadStreamingAsync(cancellationToken: cts.Token); - contentLength = download.Value.Details.ContentLength; - content = download.Value.Content; + download = (await blobClient.DownloadStreamingAsync(cancellationToken: cts.Token)).Value; } - sw.Stop(); + openWatch.Stop(); - var destinationLabel = destination == DiagnosticsDownloadFunction.BlobDestination.Hosted - ? "Hosted" : "CustomerSas"; - - // Enforce size cap before streaming (fast reject). - if (maxSizeBytes > 0 && contentLength > maxSizeBytes) + // The download result owns the network stream; disposing it on EVERY exit path + // (413, copy failure, client abort, success) returns the connection to the pool. + using (download) { - _logger.LogWarning( - "DiagnosticsBlobStreamer: Blob {BlobName} for tenant {TenantId} (destination={Destination}) rejected — size {SizeBytes} exceeds limit {MaxSizeBytes}", - blobName, tenantId, destinationLabel, contentLength, maxSizeBytes); - content.Dispose(); + var contentLength = download.Details.ContentLength; + var destinationLabel = destination == DiagnosticsDownloadFunction.BlobDestination.Hosted + ? "Hosted" + : "CustomerSas"; - return await req.ErrorAsync(HttpStatusCode.RequestEntityTooLarge, Constants.ApiErrorCodes.PayloadTooLarge, - $"Diagnostics package size ({contentLength / (1024 * 1024)} MB) exceeds the maximum allowed size ({adminConfig.MaxDiagnosticsDownloadSizeMB} MB)."); - } - - _logger.LogInformation( - "DiagnosticsBlobStreamer: Proxying blob {BlobName} for tenant {TenantId} (destination={Destination}), size {SizeBytes} bytes, fetch took {DurationMs}ms", - blobName, tenantId, destinationLabel, contentLength, sw.ElapsedMilliseconds); - - var props = new Dictionary - { - ["TenantId"] = tenantId, - ["BlobName"] = blobName, - ["Destination"] = destinationLabel, - }; - if (extraTelemetryProps != null) - foreach (var kv in extraTelemetryProps) props[kv.Key] = kv.Value; - - _telemetryClient.TrackEvent("DiagnosticsDownloadProxied", - properties: props, - metrics: new Dictionary + if (maxSizeBytes > 0 && contentLength > maxSizeBytes) { - ["BlobSizeBytes"] = contentLength, - ["DurationMs"] = sw.ElapsedMilliseconds, - }); + _logger.LogWarning( + "DiagnosticsBlobStreamer: Download of {BlobName} for tenant {TenantId} rejected — size {SizeBytes} exceeds limit {MaxSizeBytes}", + blobName, tenantId, contentLength, maxSizeBytes); + return await req.ErrorAsync(HttpStatusCode.RequestEntityTooLarge, Constants.ApiErrorCodes.PayloadTooLarge, + $"Diagnostics package size ({contentLength / (1024 * 1024)} MB) exceeds the configured maximum ({adminConfig.MaxDiagnosticsDownloadSizeMB} MB)."); + } - var downloadFilename = ExtractDownloadFilename(blobName); - var response = req.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/octet-stream"); - response.Headers.Add("Content-Disposition", $"attachment; filename=\"{downloadFilename}\""); - if (contentLength > 0) - response.Headers.Add("Content-Length", contentLength.ToString()); + var downloadFilename = ExtractDownloadFilename(blobName); + var response = req.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/octet-stream"); + response.Headers.Add("Content-Disposition", $"attachment; filename=\"{downloadFilename}\""); + if (contentLength > 0) + response.Headers.Add("Content-Length", contentLength.ToString()); - await content.CopyToAsync(response.Body, cts.Token); - return response; + // Open and transfer are measured separately: a slow open is the storage account, + // a slow transfer is the client or the proxy; the event fires after the copy so + // "proxied" means the bytes actually left, not that the blob merely opened. + var transferWatch = Stopwatch.StartNew(); + var completed = false; + try + { + await download.Content.CopyToAsync(response.Body, cts.Token); + completed = true; + } + finally + { + transferWatch.Stop(); + + _logger.LogInformation( + "DiagnosticsBlobStreamer: Proxied blob {BlobName} for tenant {TenantId} (destination={Destination}), size {SizeBytes} bytes, open {OpenMs}ms, transfer {TransferMs}ms, completed={Completed}", + blobName, tenantId, destinationLabel, contentLength, openWatch.ElapsedMilliseconds, transferWatch.ElapsedMilliseconds, completed); + + var props = new Dictionary + { + ["TenantId"] = tenantId, + ["BlobName"] = blobName, + ["Destination"] = destinationLabel, + ["Completed"] = completed ? "true" : "false", + }; + if (extraTelemetryProps != null) + { + foreach (var kv in extraTelemetryProps) + props[kv.Key] = kv.Value; + } + + _telemetryClient.TrackEvent("DiagnosticsDownloadProxied", + properties: props, + metrics: new Dictionary + { + ["BlobSizeBytes"] = contentLength, + ["OpenMs"] = openWatch.ElapsedMilliseconds, + ["TransferMs"] = transferWatch.ElapsedMilliseconds, + ["DurationMs"] = openWatch.ElapsedMilliseconds + transferWatch.ElapsedMilliseconds, + }); + } + + return response; + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs index 35089db1..cb0c7fe3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs @@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics private readonly BlobServiceClient _blobServiceClient; private readonly ILogger _logger; private readonly bool _usesManagedIdentity; + private readonly UserDelegationKeyCache _delegationKeys; private int _containerEnsured; public HostedDiagnosticsBlobService(IConfiguration configuration, ILogger logger) @@ -84,6 +85,8 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics throw new InvalidOperationException( "HostedDiagnosticsBlobService not configured. Set either 'AzureStorageAccountName' (Managed Identity) or 'AzureBlobStorageConnectionString'."); } + + _delegationKeys = new UserDelegationKeyCache(_blobServiceClient); } /// @@ -99,6 +102,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics _blobServiceClient = blobServiceClient ?? throw new ArgumentNullException(nameof(blobServiceClient)); _logger = logger; _usesManagedIdentity = usesManagedIdentity; + _delegationKeys = new UserDelegationKeyCache(_blobServiceClient); } // -------- Public API -------- @@ -330,8 +334,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics if (UsesManagedIdentity) { - var delegationKey = await _blobServiceClient.GetUserDelegationKeyAsync( - DateTimeOffset.UtcNow.AddMinutes(-5), expiresOn, cancellationToken); + var delegationKey = await _delegationKeys.GetAsync(expiresOn, cancellationToken); var sasUri = new BlobUriBuilder(blobClient.Uri) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 4b83a1b9..f99960ba 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -27,6 +28,10 @@ namespace AutopilotMonitor.Functions.Services private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + // Concurrent misses for one tenant (cold start, after invalidation) share one repository + // read instead of each issuing their own (audit 2026-09-05 F12). + private readonly ConcurrentDictionary>> _inFlight = new(); + public TenantConfigurationService( IConfigRepository configRepo, ILogger logger, @@ -75,6 +80,19 @@ namespace AutopilotMonitor.Functions.Services return (await ProjectAsync(cachedConfig))!; } + var load = _inFlight.GetOrAdd(cacheKey, _ => new Lazy>(() => LoadAndCacheAsync(tenantId, cacheKey))); + try + { + return await load.Value; + } + finally + { + _inFlight.TryRemove(new KeyValuePair>>(cacheKey, load)); + } + } + + private async Task LoadAndCacheAsync(string tenantId, string cacheKey) + { try { // Load from repository @@ -210,6 +228,7 @@ namespace AutopilotMonitor.Functions.Services public void InvalidateCache(string tenantId) { _cache.Remove($"tenant-config:{tenantId}"); + _inFlight.TryRemove($"tenant-config:{tenantId}", out _); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs b/src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs new file mode 100644 index 00000000..476f8edf --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/UserDelegationKeyCache.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// One user delegation key per process instead of one per SAS. Under Managed Identity every + /// blob SAS needs a delegation key from the service; minting it per download or upload URL + /// costs a storage round-trip and an AAD token exchange on the hot path (audit 2026-09-05 + /// F11). A key is valid for up to seven days; this cache mints one for + /// , hands it out while at least of + /// validity remains and the requested SAS expiry fits inside it, and re-mints under a lock + /// otherwise. The SAS itself keeps its own short lifetime and blob scope. + /// + public sealed class UserDelegationKeyCache + { + public static readonly TimeSpan KeyLifetime = TimeSpan.FromHours(24); + public static readonly TimeSpan RefreshMargin = TimeSpan.FromHours(2); + + /// Clock-skew tolerance on the key start, mirrors the SAS StartsOn. + private static readonly TimeSpan StartSkew = TimeSpan.FromMinutes(5); + + private readonly BlobServiceClient _client; + private readonly Func _utcNow; + private readonly SemaphoreSlim _gate = new(1, 1); + private UserDelegationKey? _key; + + public UserDelegationKeyCache(BlobServiceClient client, Func? utcNow = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + } + + /// + /// Returns a key that covers a SAS expiring at , minting a + /// fresh one when the cached key is missing, close to expiry or too short for the SAS. + /// + public async Task GetAsync(DateTimeOffset sasExpiresOn, CancellationToken cancellationToken = default) + { + var cached = _key; + if (Covers(cached, sasExpiresOn)) return cached!; + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cached = _key; + if (Covers(cached, sasExpiresOn)) return cached!; + + var now = _utcNow(); + var fresh = await _client.GetUserDelegationKeyAsync(now - StartSkew, now + KeyLifetime, cancellationToken) + .ConfigureAwait(false); + _key = fresh.Value; + return fresh.Value; + } + finally + { + _gate.Release(); + } + } + + private bool Covers(UserDelegationKey? key, DateTimeOffset sasExpiresOn) + { + if (key == null) return false; + var now = _utcNow(); + return key.SignedExpiresOn - now >= RefreshMargin && sasExpiresOn <= key.SignedExpiresOn; + } + } +}