211 lines
9.0 KiB
C#
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()}";
|
|
}
|
|
}
|