using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Tiku.Application.Security; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; internal sealed record TenantFeatureDefinition(string Code, bool IsCore); internal sealed record TenantSubscriptionSnapshot( Guid Id, Guid BaseOfferingVersionId, TenantSaasSubscriptionStatus Status, DateTimeOffset StartsAt, DateTimeOffset CurrentPeriodEnd); internal sealed record TenantFeatureAccessSnapshot( TenantStatus? TenantStatus, TenantSubscriptionSnapshot? Subscription, TenantFeatureDefinition[] Features, Dictionary Overrides, HashSet PurchasedFeatures) { public FeatureAccessDecision Evaluate(string featureCode, FeatureAccessOperation operation) { var normalized = Normalize(featureCode); if (TenantStatus != Tiku.Domain.Tenancy.TenantStatus.Active) { return Denied(normalized, operation, "tenant_inactive"); } var feature = Features.SingleOrDefault(value => value.Code == normalized); if (feature is null) { return Denied(normalized, operation, "feature_unavailable"); } if (feature.IsCore) { return Allowed(normalized, operation); } var hasOverride = Overrides.TryGetValue(normalized, out var overrideMode); if (hasOverride && overrideMode == TenantFeatureOverrideMode.Disabled) { return Denied(normalized, operation, "feature_disabled"); } if (Subscription is null) { return hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled ? Allowed(normalized, operation) : Denied(normalized, operation, "subscription_missing"); } var now = DateTimeOffset.UtcNow; if (operation == FeatureAccessOperation.Write && (Subscription.Status is not (TenantSaasSubscriptionStatus.Trial or TenantSaasSubscriptionStatus.Active) || Subscription.StartsAt > now || Subscription.CurrentPeriodEnd <= now)) { return Denied(normalized, operation, "subscription_read_only"); } if (Subscription.Status == TenantSaasSubscriptionStatus.Suspended) { return Denied(normalized, operation, "subscription_suspended"); } if (hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled) { return Allowed(normalized, operation); } return PurchasedFeatures.Contains(normalized) ? Allowed(normalized, operation) : Denied(normalized, operation, "feature_not_purchased"); } private static string Normalize(string value) => value.Trim().ToLowerInvariant(); private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) => new(true, null, featureCode, operation); private static FeatureAccessDecision Denied( string featureCode, FeatureAccessOperation operation, string denialCode) => new(false, denialCode, featureCode, operation); } internal interface ITenantFeatureSnapshotProvider { Task GetAsync( Guid tenantId, FeatureAccessOperation operation, CancellationToken cancellationToken = default); } internal sealed class TenantFeatureSnapshotProvider( TikuDbContext dbContext, IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger logger) : ITenantFeatureSnapshotProvider { private static readonly TimeSpan MemoryDuration = TimeSpan.FromSeconds(30); private static readonly DistributedCacheEntryOptions DistributedOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) }; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private readonly Dictionary<(Guid TenantId, FeatureAccessOperation Operation), (long SaveVersion, Task Snapshot)> requestCache = []; public Task GetAsync( Guid tenantId, FeatureAccessOperation operation, CancellationToken cancellationToken = default) { var requestKey = (tenantId, operation); var saveVersion = dbContext.SaveVersion; if (!requestCache.TryGetValue(requestKey, out var cached) || cached.SaveVersion != saveVersion) { var snapshotTask = GetCoreAsync( tenantId, operation, bypassSharedCache: cached.Snapshot is not null, cancellationToken); requestCache[requestKey] = (saveVersion, snapshotTask); return snapshotTask; } return cached.Snapshot; } private async Task GetCoreAsync( Guid tenantId, FeatureAccessOperation operation, bool bypassSharedCache, CancellationToken cancellationToken) { var cacheKey = CacheKey(tenantId, operation); if (!bypassSharedCache && memoryCache.TryGetValue(cacheKey, out var memorySnapshot) && memorySnapshot is not null) { return memorySnapshot; } var distributedCache = serviceProvider.GetService(); if (!bypassSharedCache && distributedCache is not null) { try { var cached = await distributedCache.GetStringAsync(cacheKey, cancellationToken); if (!string.IsNullOrWhiteSpace(cached)) { var distributedSnapshot = JsonSerializer.Deserialize(cached, SerializerOptions); if (distributedSnapshot is not null) { memoryCache.Set(cacheKey, distributedSnapshot, MemoryDuration); return distributedSnapshot; } } } catch (Exception exception) when (exception is not OperationCanceledException) { logger.LogWarning(exception, "Tenant feature snapshot cache read failed; falling back to PostgreSQL."); } } var snapshot = await LoadAsync(tenantId, operation, cancellationToken); memoryCache.Set(cacheKey, snapshot, MemoryDuration); if (distributedCache is not null) { try { await distributedCache.SetStringAsync( cacheKey, JsonSerializer.Serialize(snapshot, SerializerOptions), DistributedOptions, cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { logger.LogWarning(exception, "Tenant feature snapshot cache write failed; continuing without Redis."); } } return snapshot; } private async Task LoadAsync( Guid tenantId, FeatureAccessOperation operation, CancellationToken cancellationToken) { var tenant = await dbContext.Tenants.AsNoTracking() .Where(value => value.Id == tenantId) .Select(value => new { Status = (TenantStatus?)value.Status, Subscription = dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => subscription.TenantId == tenantId) .OrderByDescending(subscription => subscription.UpdatedAt) .Select(subscription => new TenantSubscriptionSnapshot( subscription.Id, subscription.BaseOfferingVersionId, subscription.Status, subscription.StartsAt, subscription.CurrentPeriodEnd)) .FirstOrDefault() }) .SingleOrDefaultAsync(cancellationToken); var features = await dbContext.SaasFeatures.AsNoTracking() .Where(value => value.Status == SaasFeatureStatus.Active) .Select(value => new TenantFeatureDefinition(value.Code, value.IsCore)) .ToArrayAsync(cancellationToken); var now = DateTimeOffset.UtcNow; var overrides = await dbContext.TenantFeatureOverrides.AsNoTracking() .Where(value => value.TenantId == tenantId && (value.ExpiresAt == null || value.ExpiresAt > now)) .ToDictionaryAsync(value => value.FeatureCode, value => value.Mode, StringComparer.Ordinal, cancellationToken); var purchased = new HashSet(StringComparer.Ordinal); if (tenant?.Subscription is { } subscription) { var eligibleVersionIds = dbContext.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id && (operation == FeatureAccessOperation.Read ? value.Status != TenantSaasSubscriptionItemStatus.Pending && value.Status != TenantSaasSubscriptionItemStatus.Scheduled && value.StartsAt <= now : value.Status == TenantSaasSubscriptionItemStatus.Active && value.StartsAt <= now && value.EndsAt > now)) .Select(value => value.OfferingVersionId) .Concat(dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.Id == subscription.Id) .Select(value => value.BaseOfferingVersionId)); purchased = (await dbContext.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => eligibleVersionIds.Contains(value.OfferingVersionId)) .Select(value => value.FeatureCode) .Distinct() .ToArrayAsync(cancellationToken)) .ToHashSet(StringComparer.Ordinal); } return new TenantFeatureAccessSnapshot( tenant?.Status, tenant?.Subscription, features, overrides, purchased); } internal static string CacheKey(Guid tenantId, FeatureAccessOperation operation) => $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}"; }