mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
ac72ebb420
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>
209 lines
7.9 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|