mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
4a33b96db2
Replace the denormalized per-tenant RateLimitRequestsPerMinute mirror (and its SaveConfigurationAsync sync job) with compute-at-read resolution: effective limit = per-tenant override ?? global default. Fixes the clobbering bug where a per-tenant edit to the base field was overwritten on the next global config save. - Model: drop TenantConfiguration.RateLimitRequestsPerMinute; add nullable CustomUserRateLimitRequestsPerMinute (device override CustomRateLimit... kept). - RateLimitResolver centralizes precedence (device + user); both paths call it. - SecurityValidator reads the global default (AdminConfigurationService threaded through ValidateSecurityAsync + all device-auth callers); 5-min cached. - UserRateLimitMiddleware: per-tenant user override for tenant-scoped callers only (skipped for HasGlobalScope = GlobalAdmin|GlobalReader); GA -> GA budget, others -> user default. Uses GetConfigurationIfExistsAsync so an unregistered-tenant JWT can no longer auto-create a config row via read endpoints. - Validation: reject rate limits < 1 at the write functions; RateLimitService clamps < 1 to 1 as a safety net (avoids Min()-on-empty / throttle-everything). - Web: Global Settings exposes User + GlobalAdmin limits; tenant modal writes the override fields (blank = inherit); report + usage-plans updated. Legacy RateLimitRequestsPerMinute Table column is ignored on read and dropped on the next Replace save (self-cleaning; no migration). Backend 2577 xUnit green, tsc clean, web 506 vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
6.3 KiB
C#
162 lines
6.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Services
|
|
{
|
|
/// <summary>
|
|
/// Service for managing global admin configuration.
|
|
/// Caching and business logic layer — delegates storage to IConfigRepository.
|
|
/// </summary>
|
|
public class AdminConfigurationService
|
|
{
|
|
private readonly IConfigRepository _configRepo;
|
|
private readonly ILogger<AdminConfigurationService> _logger;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
private const string CacheKey = "admin-config";
|
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
|
|
|
public AdminConfigurationService(IConfigRepository configRepo, ILogger<AdminConfigurationService> logger, IMemoryCache cache)
|
|
{
|
|
_configRepo = configRepo;
|
|
_logger = logger;
|
|
_cache = cache;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets global admin configuration (uses cache with 5-minute TTL).
|
|
/// <para>
|
|
/// Virtual so it can be mocked via Moq in consumer tests that need controlled
|
|
/// flag values (e.g. <c>IndexReconcileTimer</c> flag-gate tests).
|
|
/// </para>
|
|
/// </summary>
|
|
public virtual async Task<AdminConfiguration> GetConfigurationAsync()
|
|
{
|
|
if (_cache.TryGetValue(CacheKey, out AdminConfiguration? cachedConfig) && cachedConfig != null)
|
|
{
|
|
return cachedConfig;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Load from repository
|
|
var config = await _configRepo.GetAdminConfigurationAsync();
|
|
|
|
if (config != null)
|
|
{
|
|
_cache.Set(CacheKey, config, CacheDuration);
|
|
return config;
|
|
}
|
|
|
|
// Configuration not found - create and save default immediately
|
|
_logger.LogInformation("Admin configuration not found, creating and saving default configuration");
|
|
var defaultConfig = AdminConfiguration.CreateDefault();
|
|
|
|
try
|
|
{
|
|
// Save the default configuration via repository
|
|
await _configRepo.SaveAdminConfigurationAsync(defaultConfig);
|
|
|
|
_cache.Set(CacheKey, defaultConfig, CacheDuration);
|
|
|
|
_logger.LogInformation("Default admin configuration created and saved");
|
|
}
|
|
catch (Exception saveEx)
|
|
{
|
|
_logger.LogError(saveEx, "Failed to save default admin configuration");
|
|
}
|
|
|
|
return defaultConfig;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading admin configuration");
|
|
// Return default on error (fail-open)
|
|
return AdminConfiguration.CreateDefault();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cache-bypassing read of <see cref="AdminConfiguration.SessionDeletionKillSwitch"/>
|
|
/// (plan §5 PR5 finding 1). The general-purpose <see cref="GetConfigurationAsync"/>
|
|
/// caches for 5 minutes per instance; that's wrong for an *emergency* switch — a
|
|
/// flip-ON would not be honored across scaled-out Function-host instances until each
|
|
/// one's cache expires independently. This helper goes straight to the repository so
|
|
/// every call observes the current row.
|
|
/// <para>
|
|
/// <b>Fail-CLOSED on storage error:</b> if the repo throws, we return <c>true</c>
|
|
/// (treat as kill-switch active). For an emergency switch, blocking new deletes on a
|
|
/// transient storage failure is the safe default; the alternative would be silently
|
|
/// failing to honor an in-progress operator action during the incident the switch was
|
|
/// flipped to mitigate.
|
|
/// </para>
|
|
/// <para>
|
|
/// Virtual so tests can mock it without going through the repository.
|
|
/// </para>
|
|
/// </summary>
|
|
public virtual async Task<bool> IsSessionDeletionKillSwitchActiveAsync()
|
|
{
|
|
try
|
|
{
|
|
var config = await _configRepo.GetAdminConfigurationAsync();
|
|
return config?.SessionDeletionKillSwitch ?? false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Failed to read SessionDeletionKillSwitch from repository; failing CLOSED (treating as active) — plan §5 PR5 finding 1");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves global admin configuration and syncs rate limit to all tenant configurations
|
|
/// </summary>
|
|
public async Task SaveConfigurationAsync(AdminConfiguration config)
|
|
{
|
|
if (config == null)
|
|
{
|
|
throw new ArgumentException("Configuration is required");
|
|
}
|
|
|
|
try
|
|
{
|
|
config.LastUpdated = DateTime.UtcNow;
|
|
|
|
await _configRepo.SaveAdminConfigurationAsync(config);
|
|
|
|
// Invalidate cache
|
|
_cache.Remove(CacheKey);
|
|
|
|
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
|
|
|
|
// NOTE: Rate limits are no longer mirrored into every tenant row. The effective
|
|
// per-tenant limit is computed at read time as `tenantOverride ?? global`
|
|
// (see SecurityValidator for the device path and UserRateLimitMiddleware for the
|
|
// user path). This removes the former background sync job — and the clobbering
|
|
// foot-gun where a per-tenant edit to the base field was overwritten on the next
|
|
// global save.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error saving admin configuration");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invalidates cache (forces reload on next request)
|
|
/// </summary>
|
|
public void InvalidateCache()
|
|
{
|
|
_cache.Remove(CacheKey);
|
|
}
|
|
}
|
|
}
|