perf: optimize authorization scoreline and workers

This commit is contained in:
2026-07-30 09:12:27 +08:00
parent 4bea745b79
commit bedc77bffd
52 changed files with 21911 additions and 347 deletions

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Application.Auth;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
@@ -10,6 +11,7 @@ namespace Tiku.Infrastructure.Security;
internal sealed class CurrentAccessContext(
ICurrentUser currentUser,
ITenantContext tenantContext,
IRequestSecurityState requestSecurityState,
TikuDbContext dbContext) : ICurrentAccessContext
{
private Task<CurrentAccessSnapshot>? snapshotTask;
@@ -28,23 +30,30 @@ internal sealed class CurrentAccessContext(
return Empty();
}
var isUserActive = await dbContext.Users.AsNoTracking()
.AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken);
if (!isUserActive)
var validatedSession = requestSecurityState.ValidatedSession;
var isValidated = validatedSession is not null &&
validatedSession.UserId == userId &&
validatedSession.TenantId == tenantContext.TenantId;
if (!isValidated)
{
return new CurrentAccessSnapshot(
userId,
tenantContext.TenantId,
false,
false,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal),
CurrentDataScope.Self);
var isUserActive = await dbContext.Users.AsNoTracking()
.AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken);
if (!isUserActive)
{
return new CurrentAccessSnapshot(
userId,
tenantContext.TenantId,
false,
false,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal),
CurrentDataScope.Self);
}
}
var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken);
if (tenantContext.TenantId is not { } tenantId)
{
var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken);
return new CurrentAccessSnapshot(
userId,
null,
@@ -55,25 +64,27 @@ internal sealed class CurrentAccessContext(
CurrentDataScope.Self);
}
var isTenantActive = await dbContext.Tenants.AsNoTracking()
.AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken);
var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking()
.AnyAsync(
membership => membership.TenantId == tenantId &&
membership.UserId == userId &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (!isActiveMember)
if (!isValidated)
{
return new CurrentAccessSnapshot(
userId,
tenantId,
true,
false,
new HashSet<string>(StringComparer.Ordinal),
platformPermissions,
CurrentDataScope.Self);
var isTenantActive = await dbContext.Tenants.AsNoTracking()
.AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken);
var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking()
.AnyAsync(
membership => membership.TenantId == tenantId &&
membership.UserId == userId &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (!isActiveMember)
{
return new CurrentAccessSnapshot(
userId,
tenantId,
true,
false,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal),
CurrentDataScope.Self);
}
}
var tenantRoles = await (
@@ -106,7 +117,7 @@ internal sealed class CurrentAccessContext(
true,
true,
tenantPermissions,
platformPermissions,
new HashSet<string>(StringComparer.Ordinal),
CurrentDataScope.Merge(tenantRoles.Select(role => role.DataScope)));
}

View File

@@ -6,7 +6,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAccessService
internal sealed class FeatureAccessService(
TikuDbContext dbContext,
ITenantFeatureSnapshotProvider snapshotProvider) : IFeatureAccessService
{
public async Task<FeatureAccessDecision> EvaluateAsync(
Guid tenantId,
@@ -14,97 +16,8 @@ internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAc
FeatureAccessOperation operation,
CancellationToken cancellationToken = default)
{
var normalized = Normalize(featureCode);
var tenantStatus = await dbContext.Tenants.AsNoTracking()
.Where(value => value.Id == tenantId)
.Select(value => (TenantStatus?)value.Status)
.SingleOrDefaultAsync(cancellationToken);
if (tenantStatus != TenantStatus.Active)
{
return Denied(normalized, operation, "tenant_inactive");
}
var feature = await dbContext.SaasFeatures.AsNoTracking()
.Where(value => value.Code == normalized)
.Select(value => new { value.Status, value.IsCore })
.SingleOrDefaultAsync(cancellationToken);
if (feature is null || feature.Status != SaasFeatureStatus.Active)
{
return Denied(normalized, operation, "feature_unavailable");
}
if (feature.IsCore)
{
return Allowed(normalized, operation);
}
var now = DateTimeOffset.UtcNow;
var overrideMode = await dbContext.TenantFeatureOverrides.AsNoTracking()
.Where(value => value.TenantId == tenantId && value.FeatureCode == normalized &&
(value.ExpiresAt == null || value.ExpiresAt > now))
.Select(value => (TenantFeatureOverrideMode?)value.Mode)
.SingleOrDefaultAsync(cancellationToken);
if (overrideMode == TenantFeatureOverrideMode.Disabled)
{
return Denied(normalized, operation, "feature_disabled");
}
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenantId)
.OrderByDescending(value => value.UpdatedAt)
.Select(value => new
{
value.Id,
value.BaseOfferingVersionId,
value.Status,
value.StartsAt,
value.CurrentPeriodEnd
})
.FirstOrDefaultAsync(cancellationToken);
if (subscription is null)
{
return overrideMode == TenantFeatureOverrideMode.Enabled
? Allowed(normalized, operation)
: Denied(normalized, operation, "subscription_missing");
}
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 (overrideMode == TenantFeatureOverrideMode.Enabled)
{
return Allowed(normalized, operation);
}
var versionIds = await 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)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
{
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
}
var entitled = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
.AnyAsync(value => versionIds.Contains(value.OfferingVersionId) && value.FeatureCode == normalized, cancellationToken);
return entitled
? Allowed(normalized, operation)
: Denied(normalized, operation, "feature_not_purchased");
return (await snapshotProvider.GetAsync(tenantId, operation, cancellationToken))
.Evaluate(featureCode, operation);
}
public async Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
@@ -112,20 +25,11 @@ internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAc
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default)
{
var codes = await dbContext.SaasFeatures.AsNoTracking()
.Where(value => value.Status == SaasFeatureStatus.Active)
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
var enabled = new HashSet<string>(StringComparer.Ordinal);
foreach (var code in codes)
{
if ((await EvaluateAsync(tenantId, code, operation, cancellationToken)).Allowed)
{
enabled.Add(code);
}
}
return enabled;
var snapshot = await snapshotProvider.GetAsync(tenantId, operation, cancellationToken);
return snapshot.Features
.Where(feature => snapshot.Evaluate(feature.Code, operation).Allowed)
.Select(feature => feature.Code)
.ToHashSet(StringComparer.Ordinal);
}
public async Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
@@ -142,11 +46,12 @@ internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAc
where requested.Contains(permission.Code)
select new { permission.Code, module.RequiredFeatureCode })
.ToArrayAsync(cancellationToken);
var snapshot = await snapshotProvider.GetAsync(tenantId, operation, cancellationToken);
var allowed = new HashSet<string>(StringComparer.Ordinal);
foreach (var permission in permissions)
{
if (permission.RequiredFeatureCode is null ||
(await EvaluateAsync(tenantId, permission.RequiredFeatureCode, operation, cancellationToken)).Allowed)
snapshot.Evaluate(permission.RequiredFeatureCode, operation).Allowed)
{
allowed.Add(permission.Code);
}

View File

@@ -0,0 +1,87 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Infrastructure.Security;
internal sealed class TenantFeatureCacheInvalidator(
IMemoryCache memoryCache,
IServiceProvider serviceProvider,
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
ILogger<TenantFeatureCacheInvalidator> logger) : ITenantFeatureCacheInvalidator, IHostedService
{
private const string ChannelName = "tiku:tenant-feature-snapshot:invalidate:v1";
private ISubscriber? subscriber;
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
RemoveMemory(tenantId);
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
var distributedCache = serviceProvider.GetService<IDistributedCache>();
if (distributedCache is not null)
{
try
{
await Task.WhenAll(
distributedCache.RemoveAsync(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read), cancellationToken),
distributedCache.RemoveAsync(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write), cancellationToken));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogWarning(exception, "Tenant feature distributed cache invalidation failed for tenant {TenantId}.", tenantId);
}
}
var connection = serviceProvider.GetService<IConnectionMultiplexer>();
if (connection is not null)
{
try
{
await connection.GetSubscriber()
.PublishAsync(RedisChannel.Literal(ChannelName), tenantId.ToString("N"))
.WaitAsync(cancellationToken);
}
catch (Exception exception) when (exception is RedisException or TimeoutException)
{
logger.LogWarning(exception, "Tenant feature L1 invalidation broadcast failed for tenant {TenantId}.", tenantId);
}
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var connection = serviceProvider.GetService<IConnectionMultiplexer>();
if (connection is null)
{
return;
}
subscriber = connection.GetSubscriber();
await subscriber.SubscribeAsync(RedisChannel.Literal(ChannelName), (_, value) =>
{
if (Guid.TryParseExact(value.ToString(), "N", out var tenantId))
{
RemoveMemory(tenantId);
}
}).WaitAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (subscriber is not null)
{
await subscriber.UnsubscribeAsync(RedisChannel.Literal(ChannelName)).WaitAsync(cancellationToken);
}
}
private void RemoveMemory(Guid tenantId)
{
memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read));
memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write));
}
}

View 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()}";
}