Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs
T
Oliver Kieselbach e704aa7359 Activation seats the onboarding admin before it activates the tenant; the first-user seat belongs to that member alone (D-378)
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy
admin row a few storage round-trips later. In between the tenant was activated with no
members, and auth/me seated any roleless caller of that moment as Admin; every member of the
customer's Entra tenant could poll auth/me across the auto-approve moment.

- TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional
  insert, best-effort, fresh config read), then the activation row (still the arbiter), then
  the welcome mail. The two tables cannot share a batch, so "before" is the guarantee.
- AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes
  to that UPN only; other roleless callers stay roleless. Without one (rows from before the
  field existed) the first sign-in still becomes the admin. The grant logs at Warning.
- Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync,
  409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync
  replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check.
- Dead after this: IsTenantAdminAsync on the service, the repository interface and the table
  repository, plus its test.
- Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the
  conditional write; each part was mutated back to the old code and failed its tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 20:47:53 +02:00

170 lines
6.9 KiB
C#

using AutopilotMonitor.Functions.DataAccess.TableStorage;
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 the tenant-activation whitelist (table: PreviewWhitelist —
/// legacy name kept on purpose, no data migration). Tenants in this list are activated
/// for full portal access; others see the activation-pending page.
/// Caching and business logic layer — delegates storage to IConfigRepository.
/// </summary>
public class PreviewWhitelistService
{
private readonly IConfigRepository _configRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<PreviewWhitelistService> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public PreviewWhitelistService(
IConfigRepository configRepo,
IMemoryCache cache,
ILogger<PreviewWhitelistService> logger,
TenantConfigurationService tenantConfigService)
{
_configRepo = configRepo;
_cache = cache;
_logger = logger;
_tenantConfigService = tenantConfigService;
}
/// <summary>
/// Checks whether a tenant is activated. Only an activation is cached: "not activated" is read
/// fresh on every call, because the activation is written on the auto-approve worker (another
/// instance, out of reach of this cache) while the activation page polls auth/me. The
/// notification-email save path relies on this, too: it must see an activation that landed
/// just before its own write (welcome-mail arbitration).
/// </summary>
public virtual async Task<bool> IsApprovedAsync(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
return false;
var cacheKey = $"preview:{tenantId}";
if (_cache.TryGetValue<bool>(cacheKey, out var approved))
return approved;
try
{
var result = await _configRepo.IsInPreviewWhitelistAsync(tenantId);
if (result)
_cache.Set(cacheKey, true, CacheDuration);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking preview whitelist for tenant {TenantId}", tenantId);
// Fail-closed: if we can't check, deny access
return false;
}
}
/// <summary>
/// Uncached read of the activation, for the activation path's own pre-check: a cached activation can
/// outlive a revoke on another instance by up to 5 minutes, and a re-activation must not be answered
/// as "already activated" from that cache. Storage errors throw.
/// </summary>
public virtual Task<bool> IsApprovedFreshAsync(string tenantId)
=> _configRepo.IsInPreviewWhitelistAsync(tenantId);
/// <summary>
/// Activates a tenant (adds it to the whitelist). Returns true when this call created
/// the entry, false when the tenant was already activated — the conditional insert at
/// the storage layer arbitrates concurrent activations, so exactly one caller ever
/// sees true and runs the activation side effects. Storage errors throw.
/// </summary>
public virtual async Task<bool> ApproveAsync(string tenantId, string approvedBy)
{
var newlyApproved = await _configRepo.AddToPreviewWhitelistAsync(tenantId, approvedBy);
_cache.Remove($"preview:{tenantId}");
if (newlyApproved)
{
_logger.LogInformation("Tenant {TenantId} approved for preview by {ApprovedBy}", tenantId, approvedBy);
}
return newlyApproved;
}
/// <summary>
/// Revokes a tenant's activation.
/// </summary>
public async Task RevokeAsync(string tenantId)
{
await _configRepo.RemoveFromPreviewWhitelistAsync(tenantId);
// A re-approve after revoke is a fresh activation and should send a fresh
// welcome mail — drop the once-only marker along with the approval.
await _configRepo.ClearWelcomeEmailSentMarkerAsync(tenantId);
_cache.Remove($"preview:{tenantId}");
_logger.LogInformation("Tenant {TenantId} revoked from preview", tenantId);
}
/// <summary>
/// Returns all approved tenants (for Global Admin overview).
/// </summary>
public async Task<List<PreviewWhitelistTenantEntry>> GetAllApprovedAsync()
{
var tenantIds = await _configRepo.GetPreviewWhitelistAsync();
return tenantIds.Select(id => new PreviewWhitelistTenantEntry { TenantId = id }).ToList();
}
/// <summary>
/// Gets the notification email for a tenant (stored in PreviewWhitelist table).
/// </summary>
public virtual async Task<string?> GetNotificationEmailAsync(string tenantId)
{
return await _configRepo.GetNotificationEmailAsync(tenantId);
}
/// <summary>
/// Every stored notification address, keyed by lowercased tenant id. Uncached: the operator
/// console reads this on demand to resolve an address back to its tenant, and a stale map
/// would silently hide a tenant that just saved one.
/// </summary>
public virtual async Task<Dictionary<string, string>> GetAllNotificationEmailsAsync()
{
return await _configRepo.GetAllNotificationEmailsAsync();
}
/// <summary>
/// Consumes the once-per-activation welcome-email marker (conditional insert at the
/// storage layer). True = this caller won and may send. Storage errors throw.
/// </summary>
public virtual async Task<bool> TryMarkWelcomeEmailSentAsync(string tenantId)
{
return await _configRepo.TryMarkWelcomeEmailSentAsync(tenantId);
}
/// <summary>
/// Releases the welcome-email marker again after a send that never reached the customer.
/// The marker records delivery, not an attempt — keeping it after a refused send would
/// suppress this tenant's welcome mail forever. Fail-soft at the storage layer.
/// </summary>
public virtual async Task ClearWelcomeEmailSentMarkerAsync(string tenantId)
{
await _configRepo.ClearWelcomeEmailSentMarkerAsync(tenantId);
}
/// <summary>
/// Saves (or clears) the notification email for a tenant, and seeds the tenant's
/// contact address from it the first time one is given.
/// <para>
/// The seed is a side effect of a write that has already been persisted, so it must never
/// fail this call — <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/> is
/// fail-soft and owns the "never overwrite what the tenant owns" invariant.
/// </para>
/// </summary>
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
{
await _configRepo.SaveNotificationEmailAsync(tenantId, email);
await _tenantConfigService.TrySeedContactEmailAsync(tenantId, email);
}
}