forked from xiongyuxing/tiku-backend.net
263 lines
11 KiB
C#
263 lines
11 KiB
C#
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<string, TenantFeatureOverrideMode> Overrides,
|
|
HashSet<string> 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<TenantFeatureAccessSnapshot> GetAsync(
|
|
Guid tenantId,
|
|
FeatureAccessOperation operation,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
internal sealed class TenantFeatureSnapshotProvider(
|
|
TikuDbContext dbContext,
|
|
IMemoryCache memoryCache,
|
|
IServiceProvider serviceProvider,
|
|
ILogger<TenantFeatureSnapshotProvider> 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<TenantFeatureAccessSnapshot> Snapshot)> requestCache = [];
|
|
|
|
public Task<TenantFeatureAccessSnapshot> 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<TenantFeatureAccessSnapshot> GetCoreAsync(
|
|
Guid tenantId,
|
|
FeatureAccessOperation operation,
|
|
bool bypassSharedCache,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = CacheKey(tenantId, operation);
|
|
if (!bypassSharedCache &&
|
|
memoryCache.TryGetValue<TenantFeatureAccessSnapshot>(cacheKey, out var memorySnapshot) &&
|
|
memorySnapshot is not null)
|
|
{
|
|
return memorySnapshot;
|
|
}
|
|
|
|
var distributedCache = serviceProvider.GetService<IDistributedCache>();
|
|
if (!bypassSharedCache && distributedCache is not null)
|
|
{
|
|
try
|
|
{
|
|
var cached = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
|
|
if (!string.IsNullOrWhiteSpace(cached))
|
|
{
|
|
var distributedSnapshot = JsonSerializer.Deserialize<TenantFeatureAccessSnapshot>(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<TenantFeatureAccessSnapshot> 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<string>(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()}";
|
|
}
|