Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
T
Oliver Kieselbach ac72ebb420 fix(backend): delegated config/all must not silently drop tenants on read errors
Codex Medium on 43a87ff7: the delegated subset was built from
TryGetConfigurationAsync, which maps ANY storage failure to exists=false —
transient table throttling made managed tenants vanish from a 200 response,
whereas the replaced scan path rethrew and returned 500.

- New TenantConfigurationService.GetConfigurationIfExistsAsync: strict
  point-read (cached, non-creating, null on 404) that PROPAGATES storage
  errors; TryGetConfigurationAsync now delegates to it and keeps its
  fail-closed mapping for agent security gates.
- Delegated config/all reads via the strict method → a read failure fails
  the request (500) instead of shrinking the subset.
- Adjacent regressions from the same hunk: AllowedTenantIds deduped
  case-insensitively (old HashSet-filter semantics), and ids missed by the
  exact-case point-read get one scan+filter rescue pass — config PartitionKey
  casing is not guaranteed (agent ingest auto-creates rows verbatim; same
  caveat the delegated sessions fan-out handles case-insensitively).

Functions suite 2548/2548 (9 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:00:16 +02:00

209 lines
7.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Service for managing tenant-specific configuration.
/// Caching and business logic layer — delegates storage to IConfigRepository.
/// </summary>
public class TenantConfigurationService
{
private readonly IConfigRepository _configRepo;
private readonly ILogger<TenantConfigurationService> _logger;
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
{
_configRepo = configRepo;
_logger = logger;
_cache = cache;
}
/// <summary>
/// Gets configuration for a tenant (uses cache with 5-minute TTL)
/// </summary>
public virtual async Task<TenantConfiguration> GetConfigurationAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
{
_logger.LogWarning("GetConfiguration called with empty tenantId");
return TenantConfiguration.CreateDefault("unknown");
}
var cacheKey = $"tenant-config:{tenantId}";
if (_cache.TryGetValue(cacheKey, out TenantConfiguration? cachedConfig) && cachedConfig != null)
{
return cachedConfig;
}
try
{
// Load from repository
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (config != null)
{
_cache.Set(cacheKey, config, CacheDuration);
return config;
}
// Configuration not found - create and save default immediately
_logger.LogInformation($"Configuration not found for tenant {tenantId}, creating and saving default configuration");
var defaultConfig = TenantConfiguration.CreateDefault(tenantId);
try
{
// Save the default configuration via repository
await _configRepo.SaveTenantConfigurationAsync(defaultConfig);
_cache.Set(cacheKey, defaultConfig, CacheDuration);
_logger.LogInformation($"Default configuration created and saved for tenant {tenantId}");
}
catch (Exception saveEx)
{
_logger.LogError(saveEx, $"Failed to save default configuration for tenant {tenantId}");
}
return defaultConfig;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error loading configuration for tenant {tenantId}");
// Return default on error (fail-open for now, can be changed to fail-closed)
return TenantConfiguration.CreateDefault(tenantId);
}
}
/// <summary>
/// Saves configuration for a tenant
/// </summary>
public virtual async Task SaveConfigurationAsync(TenantConfiguration config)
{
if (config == null || string.IsNullOrEmpty(config.TenantId))
{
throw new ArgumentException("Configuration and TenantId are required");
}
try
{
config.LastUpdated = DateTime.UtcNow;
await _configRepo.SaveTenantConfigurationAsync(config);
// Invalidate cache
_cache.Remove($"tenant-config:{config.TenantId}");
_logger.LogInformation($"Configuration saved for tenant {config.TenantId} by {config.UpdatedBy}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error saving configuration for tenant {config.TenantId}");
throw;
}
}
/// <summary>
/// Invalidates cache for a tenant (forces reload on next request)
/// </summary>
public void InvalidateCache(string tenantId)
{
_cache.Remove($"tenant-config:{tenantId}");
}
/// <summary>
/// Strict point-read: returns the config, or null when no row exists (404) — does NOT
/// auto-create. Any other storage failure PROPAGATES to the caller, so "read failed" can
/// never be conflated with "tenant not configured". Use where a dropped tenant is worse
/// than a failed request (e.g. the delegated config/all subset).
/// </summary>
public virtual async Task<TenantConfiguration?> GetConfigurationIfExistsAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
return null;
var cacheKey = $"tenant-config:{tenantId}";
if (_cache.TryGetValue(cacheKey, out TenantConfiguration? cachedConfig) && cachedConfig != null)
return cachedConfig;
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (config != null)
_cache.Set(cacheKey, config, CacheDuration);
return config;
}
/// <summary>
/// Returns (config, exists). exists=false when no row was found — does NOT auto-create.
/// Use for agent security gates where unknown tenants must be rejected: storage errors are
/// mapped to exists=false (fail closed). Callers that must instead surface read failures
/// use <see cref="GetConfigurationIfExistsAsync"/>.
/// </summary>
public virtual async Task<(TenantConfiguration config, bool exists)> TryGetConfigurationAsync(string tenantId)
{
if (string.IsNullOrEmpty(tenantId))
return (TenantConfiguration.CreateDefault("unknown"), false);
try
{
var config = await GetConfigurationIfExistsAsync(tenantId);
if (config != null)
return (config, true);
return (TenantConfiguration.CreateDefault(tenantId), false);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error loading configuration for tenant {tenantId} in TryGetConfigurationAsync");
// On error, treat as non-existent to fail safely
return (TenantConfiguration.CreateDefault(tenantId), false);
}
}
/// <summary>
/// Gets all tenant configurations (for Global Admin use)
/// </summary>
public async Task<List<TenantConfiguration>> GetAllConfigurationsAsync()
{
try
{
return await _configRepo.GetAllTenantConfigurationsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading all tenant configurations");
throw;
}
}
/// <summary>
/// Gets one page of tenant configurations (for Global Admin use), ordered by TenantId.
/// Unlike <see cref="GetAllConfigurationsAsync"/> this does not load every tenant at once;
/// callers follow the page's continuation token until it is null.
/// </summary>
public async Task<RawPage<TenantConfiguration>> GetConfigurationsPageAsync(int pageSize, string? continuation)
{
try
{
return await _configRepo.GetTenantConfigurationsPageAsync(pageSize, continuation);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading tenant configurations page");
throw;
}
}
}
}