mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
98cfd76232
A tenant in the owned group of a permanent-Pro tenant is effectively Pro with source "msp":
badge "Pro (MSP)" (also over its own Pro), the Pro entitlement set without the delegation
right (no transitive delegation, no slots), no trial CTA. Nothing is written for that —
TenantConfiguration.ManagedByProTenantId is a load-time projection from a process-wide
index over the Tenant Groups table (owner tier read raw, never through the config service),
so the end of the delegation, the manager losing its permanent tier, or its offboarding
revert automatically. The one write is the retention grace anchor (ProDowngradedUtc),
stamped by ProConferralService from the four paths that end a conferring relationship.
- FeatureEntitlementCatalog.Resolve → EditionResolution { Edition, Source, OwnPro };
Get(resolution) returns ProViaMsp for conferred Pro; the 3-argument ResolveEdition is
gone so every caller resolves from the config (incl. the projection)
- DelegatedAdminService home gate checks the DelegatedAdminAllowed entitlement, not the edition
- TrialExpirySweep reads through the config service: a managed tenant's expired trial emits nothing
- feature-flags + plan response carry editionSource; config/all projects payingCustomer and
managedByProTenantId; shared manifest + web/MCP codegen regenerated
- PayingCustomer: GA-only sales bookkeeping flag on the plan endpoint, no entitlement effect
- web: "Pro (MSP)" label/tooltips, plan section badge, delegated-access hints, tenant
management chips + paying checkbox
Tests: catalog matrix, ManagedTenantProIndexTests (incl. mutual management without recursion,
fail-closed on storage error), ProConferralServiceTests, projection on every config read path,
feature-flags payload, parity facts, serialization (projection never stored), plan transitions,
edition gate, sweep. Backend 5902 green, web tsc/vitest/lint clean, MCP tsc/vitest green.
Concept doc + D-188 in internal (gitlink); customer docs in the docs repo (MCP redeploy needed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
327 lines
14 KiB
C#
327 lines
14 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.
|
|
///
|
|
/// Every configuration that leaves this service through a READ path carries the load-time projection
|
|
/// <see cref="TenantConfiguration.ManagedByProTenantId"/> (see <see cref="ManagedTenantProIndex"/>): it is
|
|
/// applied on cache hits and misses alike, so its staleness is bounded by the index TTL alone, and it is
|
|
/// never applied to a synthesized default (a tenant without a row can never be projected as Pro).
|
|
/// </summary>
|
|
public class TenantConfigurationService
|
|
{
|
|
private readonly IConfigRepository _configRepo;
|
|
private readonly ILogger<TenantConfigurationService> _logger;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly ManagedTenantProIndex _proIndex;
|
|
|
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
|
|
|
public TenantConfigurationService(
|
|
IConfigRepository configRepo,
|
|
ILogger<TenantConfigurationService> logger,
|
|
IMemoryCache cache,
|
|
ManagedTenantProIndex proIndex)
|
|
{
|
|
_configRepo = configRepo;
|
|
_logger = logger;
|
|
_cache = cache;
|
|
_proIndex = proIndex;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test seam — no delegation index: no tenant is ever projected as managed. Production DI always
|
|
/// resolves the four-argument constructor (the longest satisfiable one).
|
|
/// </summary>
|
|
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
|
|
: this(configRepo, logger, cache, ManagedTenantProIndex.None)
|
|
{
|
|
}
|
|
|
|
/// <summary>Applies the read-time projection (see the class remarks). Idempotent; null passes through.</summary>
|
|
private async Task<TenantConfiguration?> ProjectAsync(TenantConfiguration? config)
|
|
{
|
|
if (config == null)
|
|
return null;
|
|
config.ManagedByProTenantId = await _proIndex.GetConferringOwnerAsync(config.TenantId);
|
|
return config;
|
|
}
|
|
|
|
/// <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 (await ProjectAsync(cachedConfig))!;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Load from repository
|
|
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
|
|
|
if (config != null)
|
|
{
|
|
_cache.Set(cacheKey, config, CacheDuration);
|
|
return (await ProjectAsync(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. THROWS when the write did not persist — the repository
|
|
/// swallows storage exceptions and reports failure via its bool return, so ignoring it would
|
|
/// let callers audit + return 200 for a save that never happened (Codex finding, 2026-07-07).
|
|
/// The cached row is invalidated on EVERY attempted save (finally): on success it is stale;
|
|
/// on failure the caller may have mutated the cached instance in place (plan/trial endpoints),
|
|
/// which must not linger as phantom-saved state for the 5-minute TTL.
|
|
/// </summary>
|
|
public virtual Task SaveConfigurationAsync(TenantConfiguration config)
|
|
=> SaveConfigurationAsync(config, backupSource: null, backupReason: null);
|
|
|
|
/// <summary>
|
|
/// Overload (not optional parameters — Moq expression trees, CS0854) that tags the
|
|
/// pre-write backup snapshot with the write path and intent.
|
|
/// </summary>
|
|
public virtual async Task SaveConfigurationAsync(
|
|
TenantConfiguration config, string? backupSource, string? backupReason)
|
|
{
|
|
if (config == null || string.IsNullOrEmpty(config.TenantId))
|
|
{
|
|
throw new ArgumentException("Configuration and TenantId are required");
|
|
}
|
|
|
|
try
|
|
{
|
|
config.LastUpdated = DateTime.UtcNow;
|
|
|
|
var saved = await _configRepo.SaveTenantConfigurationAsync(config, backupSource, backupReason);
|
|
if (!saved)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Tenant configuration save failed for {config.TenantId} — storage write did not persist (see repository logs)");
|
|
}
|
|
|
|
_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;
|
|
}
|
|
finally
|
|
{
|
|
// Invalidate cache — success AND failure paths (see summary).
|
|
_cache.Remove($"tenant-config:{config.TenantId}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One-way seed of the tenant contact address: writes <paramref name="email"/> only while
|
|
/// the tenant has none, and only if nothing else wrote the configuration in the meantime.
|
|
/// Returns true when the seed landed.
|
|
/// <para>
|
|
/// Single owner for both seed paths (preview notification-email save and the maintenance
|
|
/// backfill), because both need the same three things: the conditional single-property
|
|
/// write, the "never overwrite what the tenant owns" invariant, and the cache invalidation
|
|
/// below — the repository writes behind this cache, so without it a freshly seeded address
|
|
/// stays invisible for the 5-minute TTL.
|
|
/// </para>
|
|
/// <para>
|
|
/// Fail-soft by design, unlike <see cref="SaveConfigurationAsync"/>: every caller is a side
|
|
/// effect of an operation that has already succeeded, and a lost seed is recoverable on the
|
|
/// next maintenance run. Enforced here rather than left to the repository, so the guarantee
|
|
/// holds for any implementation.
|
|
/// </para>
|
|
/// </summary>
|
|
public virtual async Task<bool> TrySeedContactEmailAsync(string tenantId, string? email)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var seeded = await _configRepo.TrySeedTenantContactEmailAsync(tenantId, email!.Trim());
|
|
if (seeded)
|
|
{
|
|
_cache.Remove($"tenant-config:{tenantId}");
|
|
_logger.LogInformation("Seeded contact address for tenant {TenantId}", tenantId);
|
|
}
|
|
|
|
return seeded;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"Could not seed contact address for tenant {TenantId} — the triggering write still stands", tenantId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <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 await ProjectAsync(cachedConfig);
|
|
|
|
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
|
if (config != null)
|
|
_cache.Set(cacheKey, config, CacheDuration);
|
|
|
|
return await ProjectAsync(config);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cache-BYPASSING read (refreshes this instance's cache on hit); null when no row exists,
|
|
/// never auto-creates. For read-modify-write side-effect writers that persist the WHOLE
|
|
/// entity (e.g. AuthFunction's LastAuthClientId tracking): a cached view can be up to
|
|
/// 5 minutes stale on OTHER instances, and blindly saving it would silently rewind any
|
|
/// field another writer just changed — most critically HomedAppClientId right after a
|
|
/// consent-driven app-homing flip (the exact login that follows a flip is the one that
|
|
/// carries a changed audience and triggers the tracking write).
|
|
/// </summary>
|
|
public virtual async Task<TenantConfiguration?> GetConfigurationFreshAsync(string tenantId)
|
|
{
|
|
if (string.IsNullOrEmpty(tenantId))
|
|
return null;
|
|
|
|
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
|
if (config != null)
|
|
_cache.Set($"tenant-config:{tenantId}", config, CacheDuration);
|
|
|
|
return await ProjectAsync(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
|
|
{
|
|
var configs = await _configRepo.GetAllTenantConfigurationsAsync();
|
|
foreach (var config in configs)
|
|
await ProjectAsync(config);
|
|
return configs;
|
|
}
|
|
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
|
|
{
|
|
var page = await _configRepo.GetTenantConfigurationsPageAsync(pageSize, continuation);
|
|
foreach (var config in page.Items)
|
|
await ProjectAsync(config);
|
|
return page;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading tenant configurations page");
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
}
|