forked from gongxuegit/tiku-backend.net
perf: optimize authorization scoreline and workers
This commit is contained in:
262
Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
Normal file
262
Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
Normal file
@@ -0,0 +1,262 @@
|
||||
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()}";
|
||||
}
|
||||
Reference in New Issue
Block a user