Files
tiku-backend.net/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
xiong 603bc24c26
Some checks are pending
ci / release-gate (push) Waiting to run
feat(cache): adopt FusionCache for business caching
2026-08-05 09:30:48 +08:00

211 lines
9.0 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
using ZiggyCreatures.Caching.Fusion;
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<string, TenantFeatureOverrideMode> Overrides,
HashSet<string> PurchasedFeatures)
{
public FeatureAccessDecision Evaluate(string featureCode, FeatureAccessOperation operation)
{
var normalized = Normalize(featureCode);
if (TenantStatus != 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)
{
return value.Trim().ToLowerInvariant();
}
private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation)
{
return new FeatureAccessDecision(true, null, featureCode, operation);
}
private static FeatureAccessDecision Denied(
string featureCode,
FeatureAccessOperation operation,
string denialCode)
{
return new FeatureAccessDecision(false, denialCode, featureCode, operation);
}
}
internal interface ITenantFeatureSnapshotProvider
{
Task<TenantFeatureAccessSnapshot> GetAsync(
Guid tenantId,
FeatureAccessOperation operation,
CancellationToken cancellationToken = default);
}
internal sealed class TenantFeatureSnapshotProvider(
IPlatformControlPlanePersistence platformControlPlanePersistence,
ITenancyPersistence tenancyPersistence,
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache) : ITenantFeatureSnapshotProvider
{
private readonly
Dictionary<(Guid TenantId, FeatureAccessOperation Operation), (long SaveVersion,
Task<TenantFeatureAccessSnapshot> Snapshot)> requestCache = [];
public Task<TenantFeatureAccessSnapshot> GetAsync(
Guid tenantId,
FeatureAccessOperation operation,
CancellationToken cancellationToken = default)
{
var requestKey = (tenantId, operation);
var saveVersion = platformControlPlanePersistence.SaveVersion;
if (!requestCache.TryGetValue(requestKey, out var cached) || cached.SaveVersion != saveVersion)
{
var snapshotTask = GetCoreAsync(
tenantId,
operation,
cached.Snapshot is not null,
cancellationToken);
requestCache[requestKey] = (saveVersion, snapshotTask);
return snapshotTask;
}
return cached.Snapshot;
}
private async Task<TenantFeatureAccessSnapshot> GetCoreAsync(
Guid tenantId,
FeatureAccessOperation operation,
bool bypassSharedCache,
CancellationToken cancellationToken)
{
var cacheKey = CacheKey(tenantId, operation);
return await cache.GetOrSetAsync<TenantFeatureAccessSnapshot>(
cacheKey,
(_, token) => LoadAsync(tenantId, operation, token),
options =>
{
options.Duration = TimeSpan.FromSeconds(60);
options.MemoryCacheDuration = TimeSpan.FromSeconds(30);
options.SkipMemoryCacheRead = bypassSharedCache;
options.SkipDistributedCacheRead = bypassSharedCache;
},
token: cancellationToken);
}
private async Task<TenantFeatureAccessSnapshot> LoadAsync(
Guid tenantId,
FeatureAccessOperation operation,
CancellationToken cancellationToken)
{
var tenant = await tenancyPersistence.Tenants.AsNoTracking()
.Where(value => value.Id == tenantId)
.Select(value => new
{
Status = (TenantStatus?)value.Status,
Subscription = platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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<string>(StringComparer.Ordinal);
if (tenant?.Subscription is { } subscription)
{
var eligibleVersionIds = platformControlPlanePersistence.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(platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.Id == subscription.Id)
.Select(value => value.BaseOfferingVersionId));
purchased = (await platformControlPlanePersistence.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)
{
return $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}";
}
}