feat(auth): add Redis authorization caching
This commit is contained in:
@@ -9,15 +9,22 @@ using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthSessionStore(
|
||||
TikuDbContext dbContext,
|
||||
ITokenService tokenService,
|
||||
IOptions<JwtOptions> options) : IAuthSessionStore
|
||||
IOptions<JwtOptions> options,
|
||||
IAccessSecurityCache? configuredAccessSecurityCache = null,
|
||||
IOptions<AuthorizationCacheOptions>? configuredCacheOptions = null,
|
||||
IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore
|
||||
{
|
||||
private readonly JwtOptions options = options.Value;
|
||||
private readonly IAccessSecurityCache accessSecurityCache = configuredAccessSecurityCache ?? new NullAuthorizationCache();
|
||||
private readonly AuthorizationCacheOptions cacheOptions = configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
|
||||
private readonly IAuthorizationStateInvalidator stateInvalidator = configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
|
||||
|
||||
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
|
||||
{
|
||||
@@ -80,6 +87,7 @@ public sealed class AuthSessionStore(
|
||||
|
||||
var tokenHash = HashRefreshToken(refreshToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
AuthorizationCacheTelemetry.PostgresFallback();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var current = await dbContext.AuthSessions.SingleOrDefaultAsync(
|
||||
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
|
||||
@@ -141,6 +149,7 @@ public sealed class AuthSessionStore(
|
||||
dbContext.AuthSessions.Add(next);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await stateInvalidator.InvalidateSessionAsync(current.Id, cancellationToken);
|
||||
return CreatePair(request, next, nextToken);
|
||||
}
|
||||
|
||||
@@ -151,29 +160,63 @@ public sealed class AuthSessionStore(
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lookup = new AccessSecurityCacheLookup(sessionId, userId, realm, tenantId);
|
||||
AccessSecurityCacheState? shadowState = null;
|
||||
if (cacheOptions.Mode == AuthorizationCacheMode.Active && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = await accessSecurityCache.GetAsync(lookup, cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
var platformVersionStale = realm == AuthRealm.Platform &&
|
||||
cached.PlatformAccess!.AuthorizationVersion != cached.AuthorizationVersion!.Version;
|
||||
if (!platformVersionStale)
|
||||
{
|
||||
return ValidateCached(cached, lookup);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Redis is an acceleration layer; PostgreSQL remains authoritative.
|
||||
}
|
||||
}
|
||||
else if (cacheOptions.Mode == AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
shadowState = await accessSecurityCache.GetAsync(lookup, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Shadow failures never affect the PostgreSQL-authoritative decision.
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var state = await (
|
||||
SessionValidationState? state;
|
||||
try
|
||||
{
|
||||
state = await (
|
||||
from session in dbContext.AuthSessions.AsNoTracking()
|
||||
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
|
||||
where session.Id == sessionId &&
|
||||
session.UserId == userId &&
|
||||
session.Realm == realm &&
|
||||
session.TenantId == tenantId &&
|
||||
session.RevokedAt == null &&
|
||||
session.ExpiresAt > now
|
||||
select new
|
||||
{
|
||||
UserStatus = user.Status,
|
||||
UserSecurityStamp = user.SecurityStamp,
|
||||
SessionSecurityStamp = session.SecurityStamp,
|
||||
TenantAllowed = realm != AuthRealm.Tenant ||
|
||||
session.TenantId == tenantId
|
||||
select new SessionValidationState(
|
||||
user.Status,
|
||||
user.SecurityStamp!,
|
||||
session.SecurityStamp,
|
||||
realm != AuthRealm.Tenant ||
|
||||
(tenantId != null &&
|
||||
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
|
||||
dbContext.TenantMemberships.Any(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active)),
|
||||
PlatformAllowed = realm != AuthRealm.Platform ||
|
||||
realm != AuthRealm.Platform ||
|
||||
(from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
@@ -181,21 +224,130 @@ public sealed class AuthSessionStore(
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).Any()
|
||||
})
|
||||
select permission.Id).Any(),
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.Tenants.Where(item => item.Id == tenantId).Select(item => (TenantStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && tenantId != null
|
||||
? dbContext.TenantMemberships.Where(item => item.TenantId == tenantId && item.UserId == userId)
|
||||
.Select(item => (MembershipStatus?)item.Status).FirstOrDefault()
|
||||
: null,
|
||||
dbContext.AuthorizationScopeVersions
|
||||
.Where(item => item.Realm == realm && item.TenantId == tenantId)
|
||||
.Select(item => (long?)item.Version).FirstOrDefault() ?? 1L,
|
||||
session.ExpiresAt,
|
||||
session.RevokedAt != null))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
throw new AuthorizationSecurityUnavailableException(exception);
|
||||
}
|
||||
if (state is null ||
|
||||
state.SessionRevoked ||
|
||||
state.SessionExpiresAt <= now ||
|
||||
state.UserStatus != UserStatus.Active ||
|
||||
!string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
|
||||
!state.TenantAllowed ||
|
||||
!state.PlatformAllowed)
|
||||
{
|
||||
if (state is not null &&
|
||||
cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow &&
|
||||
accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessSecurityCache.SetAsync(ToCacheState(
|
||||
state, sessionId, userId, realm, tenantId), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// A negative cache write failure does not change the denial decision.
|
||||
}
|
||||
}
|
||||
if (shadowState is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) is null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthSessionValidationResult(userId, realm, tenantId);
|
||||
var result = new AuthSessionValidationResult(userId, realm, tenantId, state.AuthorizationVersion);
|
||||
if (shadowState is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) == result);
|
||||
}
|
||||
if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessSecurityCache.SetAsync(
|
||||
ToCacheState(state, sessionId, userId, realm, tenantId), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// The database result is authoritative and remains usable.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AuthSessionValidationResult? ValidateCached(
|
||||
AccessSecurityCacheState state, AccessSecurityCacheLookup lookup)
|
||||
{
|
||||
var session = state.Session!;
|
||||
var user = state.User!;
|
||||
var version = state.AuthorizationVersion!;
|
||||
if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId ||
|
||||
session.Realm != lookup.Realm || session.TenantId != lookup.TenantId ||
|
||||
session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow ||
|
||||
user.UserId != lookup.UserId || user.Status != UserStatus.Active ||
|
||||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) ||
|
||||
version.Realm != lookup.Realm || version.TenantId != lookup.TenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (lookup.Realm == AuthRealm.Tenant &&
|
||||
(state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (lookup.Realm == AuthRealm.Platform &&
|
||||
(!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version);
|
||||
}
|
||||
|
||||
private static AccessSecurityCacheState ToCacheState(
|
||||
SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) => new(
|
||||
new CachedSessionSecurityState(sessionId, userId, realm, tenantId,
|
||||
state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked),
|
||||
new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp),
|
||||
realm == AuthRealm.Tenant && state.TenantStatus.HasValue
|
||||
? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Tenant && state.MembershipStatus.HasValue
|
||||
? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value)
|
||||
: null,
|
||||
realm == AuthRealm.Platform
|
||||
? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed)
|
||||
: null,
|
||||
new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion));
|
||||
|
||||
private sealed record SessionValidationState(
|
||||
UserStatus UserStatus,
|
||||
string UserSecurityStamp,
|
||||
string SessionSecurityStamp,
|
||||
bool TenantAllowed,
|
||||
bool PlatformAllowed,
|
||||
TenantStatus? TenantStatus,
|
||||
MembershipStatus? MembershipStatus,
|
||||
long AuthorizationVersion,
|
||||
DateTimeOffset SessionExpiresAt,
|
||||
bool SessionRevoked);
|
||||
|
||||
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
@@ -237,6 +389,9 @@ public sealed class AuthSessionStore(
|
||||
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow)
|
||||
@@ -252,6 +407,11 @@ public sealed class AuthSessionStore(
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
await stateInvalidator.InvalidateUserAsync(userId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +424,9 @@ public sealed class AuthSessionStore(
|
||||
{
|
||||
ValidateRealm(realm, tenantId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var count = await dbContext.AuthSessions
|
||||
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
@@ -281,6 +444,10 @@ public sealed class AuthSessionStore(
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +567,9 @@ public sealed class AuthSessionStore(
|
||||
|
||||
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
|
||||
.Select(item => item.Id).ToArrayAsync(cancellationToken);
|
||||
var owner = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.TokenFamilyId == familyId)
|
||||
.Select(item => new { item.UserId, item.TenantId })
|
||||
@@ -420,6 +590,10 @@ public sealed class AuthSessionStore(
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace Tiku.Infrastructure.Backoffice;
|
||||
internal sealed class BackofficeService(
|
||||
TikuDbContext dbContext,
|
||||
IOperationAuditService auditService,
|
||||
IFeatureAccessService featureAccessService) : IBackofficeService
|
||||
IFeatureAccessService featureAccessService,
|
||||
IAuthorizationStateInvalidator authorizationStateInvalidator) : IBackofficeService
|
||||
{
|
||||
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||
CurrentAccessSnapshot access,
|
||||
@@ -133,6 +134,7 @@ internal sealed class BackofficeService(
|
||||
role.Description = command.Description?.Trim();
|
||||
role.DataScope = command.DataScope ?? JsonDefaults.Object();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
|
||||
}
|
||||
@@ -160,6 +162,7 @@ internal sealed class BackofficeService(
|
||||
role.Status = command.Status;
|
||||
role.Description = command.Description?.Trim();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
|
||||
}
|
||||
@@ -179,6 +182,7 @@ internal sealed class BackofficeService(
|
||||
}
|
||||
|
||||
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
|
||||
}
|
||||
@@ -198,6 +202,7 @@ internal sealed class BackofficeService(
|
||||
}
|
||||
|
||||
await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
||||
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
|
||||
}
|
||||
@@ -242,6 +247,7 @@ internal sealed class BackofficeService(
|
||||
RoleId = roleId
|
||||
}));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -269,6 +275,7 @@ internal sealed class BackofficeService(
|
||||
RoleId = roleId
|
||||
}));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
.SingleOrDefaultAsync(item => item.Mode == TenantMode.PlatformOwned, cancellationToken);
|
||||
if (platformOwnedTenant is null)
|
||||
{
|
||||
dbContext.Tenants.Add(new Tenant
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Slug = "platform-public-content",
|
||||
Name = "平台公共内容",
|
||||
@@ -140,6 +140,12 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
Mode = TenantMode.PlatformOwned,
|
||||
BillingStatus = BillingStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantId = tenant.Id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,11 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantId = tenant.Id
|
||||
});
|
||||
return tenant;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ public static class DependencyInjection
|
||||
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
|
||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||
services.AddSingleton<IRedisSecurityStore, NullRedisSecurityStore>();
|
||||
services.AddSingleton<NullAuthorizationCache>();
|
||||
services.AddSingleton<IAccessSecurityCache>(provider => provider.GetRequiredService<NullAuthorizationCache>());
|
||||
services.AddSingleton<IAuthorizationSnapshotCache>(provider => provider.GetRequiredService<NullAuthorizationCache>());
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
||||
@@ -105,6 +108,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<AuthSessionStore>();
|
||||
services.AddScoped<IAuthSessionStore>(provider => provider.GetRequiredService<AuthSessionStore>());
|
||||
services.AddScoped<IRequestAccessValidator, RequestAccessValidator>();
|
||||
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
|
||||
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
|
||||
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
|
||||
@@ -149,6 +153,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantOnboardingService, TenantOnboardingService>();
|
||||
services.AddScoped<ITenantLifecycleService, TenantLifecycleService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<IAuthorizationStateInvalidator, AuthorizationStateInvalidator>();
|
||||
services.AddScoped<IAuthorizationCacheInvalidationProcessor, AuthorizationCacheInvalidationProcessor>();
|
||||
services.AddScoped<ITenantFeatureSnapshotProvider, TenantFeatureSnapshotProvider>();
|
||||
services.AddSingleton<TenantFeatureCacheInvalidator>();
|
||||
services.AddSingleton<ITenantFeatureCacheInvalidator>(provider => provider.GetRequiredService<TenantFeatureCacheInvalidator>());
|
||||
@@ -200,6 +206,12 @@ public static class DependencyInjection
|
||||
environmentName,
|
||||
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<RedisSecurityStore>>()));
|
||||
services.AddSingleton<IRedisSecurityStore>(provider => provider.GetRequiredService<RedisSecurityStore>());
|
||||
services.AddSingleton(provider => new RedisAuthorizationCache(
|
||||
provider.GetRequiredService<IConnectionMultiplexer>(),
|
||||
provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthorizationCacheOptions>>(),
|
||||
environmentName));
|
||||
services.AddSingleton<IAccessSecurityCache>(provider => provider.GetRequiredService<RedisAuthorizationCache>());
|
||||
services.AddSingleton<IAuthorizationSnapshotCache>(provider => provider.GetRequiredService<RedisAuthorizationCache>());
|
||||
services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -264,6 +264,43 @@ internal sealed class PlatformBackendUserRoleConfiguration : IEntityTypeConfigur
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AuthorizationScopeVersionConfiguration : IEntityTypeConfiguration<AuthorizationScopeVersion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuthorizationScopeVersion> builder)
|
||||
{
|
||||
builder.ConfigureEntity("authorization_scope_versions");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Version).HasDefaultValue(1L).IsConcurrencyToken();
|
||||
builder.ToTable("authorization_scope_versions", table => table.HasCheckConstraint(
|
||||
"ck_authorization_scope_versions_realm_tenant",
|
||||
"(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"));
|
||||
builder.HasIndex(entity => entity.TenantId)
|
||||
.IsUnique()
|
||||
.HasFilter("tenant_id is not null");
|
||||
builder.HasIndex(entity => entity.Realm)
|
||||
.IsUnique()
|
||||
.HasFilter("realm = 'platform'");
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AuthorizationCacheInvalidationConfiguration : IEntityTypeConfiguration<AuthorizationCacheInvalidation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuthorizationCacheInvalidation> builder)
|
||||
{
|
||||
builder.ConfigureEntity("authorization_cache_invalidations");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.TargetType).HasMaxLength(40);
|
||||
builder.Property(entity => entity.Realm).HasConversion(
|
||||
value => value.HasValue ? value.Value.ToString().ToLowerInvariant() : null,
|
||||
value => value == null ? null : Enum.Parse<AuthRealm>(value, true));
|
||||
builder.Property(entity => entity.LastError).HasMaxLength(2000);
|
||||
builder.HasIndex(entity => new { entity.ProcessedAt, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TargetType, entity.TenantId, entity.UserId, entity.SessionId, entity.Version });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration<BackgroundJob>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BackgroundJob> builder)
|
||||
|
||||
20180
Tiku.Infrastructure/Persistence/Migrations/20260801033749_AddAuthorizationCaching.Designer.cs
generated
Normal file
20180
Tiku.Infrastructure/Persistence/Migrations/20260801033749_AddAuthorizationCaching.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthorizationCaching : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "authorization_cache_invalidations",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
target_type = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
session_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
realm = table.Column<string>(type: "text", nullable: true),
|
||||
version = table.Column<long>(type: "bigint", nullable: true),
|
||||
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
attempt_count = table.Column<int>(type: "integer", nullable: false),
|
||||
last_error = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_authorization_cache_invalidations", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "authorization_scope_versions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_authorization_scope_versions", x => x.id);
|
||||
table.CheckConstraint("ck_authorization_scope_versions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
|
||||
table.ForeignKey(
|
||||
name: "fk_authorization_scope_versions_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_authorization_cache_invalidations_processed_at_created_at",
|
||||
table: "authorization_cache_invalidations",
|
||||
columns: new[] { "processed_at", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_authorization_cache_invalidations_target_type_tenant_id_use~",
|
||||
table: "authorization_cache_invalidations",
|
||||
columns: new[] { "target_type", "tenant_id", "user_id", "session_id", "version" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_authorization_scope_versions_realm",
|
||||
table: "authorization_scope_versions",
|
||||
column: "realm",
|
||||
unique: true,
|
||||
filter: "realm = 'platform'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_authorization_scope_versions_tenant_id",
|
||||
table: "authorization_scope_versions",
|
||||
column: "tenant_id",
|
||||
unique: true,
|
||||
filter: "tenant_id is not null");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO authorization_scope_versions
|
||||
(id, realm, tenant_id, version, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'platform', NULL, 1, now(), now());
|
||||
|
||||
INSERT INTO authorization_scope_versions
|
||||
(id, realm, tenant_id, version, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), 'tenant', id, 1, now(), now()
|
||||
FROM tenants;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
CREATE OR REPLACE FUNCTION tiku_bump_tenant_authorization_version()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
scope_tenant_id uuid;
|
||||
next_version bigint;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
scope_tenant_id := OLD.tenant_id;
|
||||
ELSE
|
||||
scope_tenant_id := NEW.tenant_id;
|
||||
END IF;
|
||||
|
||||
UPDATE authorization_scope_versions
|
||||
SET version = version + 1, updated_at = now()
|
||||
WHERE realm = 'tenant' AND tenant_id = scope_tenant_id
|
||||
RETURNING version INTO next_version;
|
||||
|
||||
IF next_version IS NOT NULL THEN
|
||||
INSERT INTO authorization_cache_invalidations
|
||||
(id, target_type, tenant_id, realm, version, attempt_count, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'scope', scope_tenant_id, 'tenant', next_version, 0, now(), now());
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION tiku_bump_platform_authorization_version()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE next_version bigint;
|
||||
BEGIN
|
||||
UPDATE authorization_scope_versions
|
||||
SET version = version + 1, updated_at = now()
|
||||
WHERE realm = 'platform' AND tenant_id IS NULL
|
||||
RETURNING version INTO next_version;
|
||||
INSERT INTO authorization_cache_invalidations
|
||||
(id, target_type, realm, version, attempt_count, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'scope', 'platform', next_version, 0, now(), now());
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION tiku_bump_all_authorization_versions()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE authorization_scope_versions SET version = version + 1, updated_at = now();
|
||||
INSERT INTO authorization_cache_invalidations
|
||||
(id, target_type, tenant_id, realm, version, attempt_count, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), 'scope', tenant_id, realm, version, 0, now(), now()
|
||||
FROM authorization_scope_versions;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_tenant_backend_roles_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON tenant_backend_roles
|
||||
FOR EACH ROW EXECUTE FUNCTION tiku_bump_tenant_authorization_version();
|
||||
CREATE TRIGGER trg_tenant_backend_role_permissions_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON tenant_backend_role_permissions
|
||||
FOR EACH ROW EXECUTE FUNCTION tiku_bump_tenant_authorization_version();
|
||||
CREATE TRIGGER trg_tenant_backend_user_roles_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON tenant_backend_user_roles
|
||||
FOR EACH ROW EXECUTE FUNCTION tiku_bump_tenant_authorization_version();
|
||||
|
||||
CREATE TRIGGER trg_platform_backend_roles_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON platform_backend_roles
|
||||
FOR EACH ROW EXECUTE FUNCTION tiku_bump_platform_authorization_version();
|
||||
CREATE TRIGGER trg_platform_backend_role_permissions_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON platform_backend_role_permissions
|
||||
FOR EACH ROW EXECUTE FUNCTION tiku_bump_platform_authorization_version();
|
||||
CREATE TRIGGER trg_platform_backend_user_roles_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON platform_backend_user_roles
|
||||
FOR EACH ROW EXECUTE FUNCTION tiku_bump_platform_authorization_version();
|
||||
|
||||
CREATE TRIGGER trg_backend_permissions_authorization_version
|
||||
AFTER INSERT OR UPDATE OR DELETE ON backend_permissions
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION tiku_bump_all_authorization_versions();
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DROP FUNCTION IF EXISTS tiku_bump_tenant_authorization_version() CASCADE;
|
||||
DROP FUNCTION IF EXISTS tiku_bump_platform_authorization_version() CASCADE;
|
||||
DROP FUNCTION IF EXISTS tiku_bump_all_authorization_versions() CASCADE;
|
||||
""");
|
||||
migrationBuilder.DropTable(
|
||||
name: "authorization_cache_invalidations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "authorization_scope_versions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10235,6 +10235,133 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("audit_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.AuthorizationCacheInvalidation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<int>("AttemptCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("attempt_count");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<string>("Realm")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("realm");
|
||||
|
||||
b.Property<Guid?>("SessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)")
|
||||
.HasColumnName("target_type");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<long?>("Version")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_authorization_cache_invalidations");
|
||||
|
||||
b.HasIndex("ProcessedAt", "CreatedAt")
|
||||
.HasDatabaseName("ix_authorization_cache_invalidations_processed_at_created_at");
|
||||
|
||||
b.HasIndex("TargetType", "TenantId", "UserId", "SessionId", "Version")
|
||||
.HasDatabaseName("ix_authorization_cache_invalidations_target_type_tenant_id_use~");
|
||||
|
||||
b.ToTable("authorization_cache_invalidations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.AuthorizationScopeVersion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Realm")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("realm");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<long>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(1L)
|
||||
.HasColumnName("version");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_authorization_scope_versions");
|
||||
|
||||
b.HasIndex("Realm")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_authorization_scope_versions_realm")
|
||||
.HasFilter("realm = 'platform'");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_authorization_scope_versions_tenant_id")
|
||||
.HasFilter("tenant_id is not null");
|
||||
|
||||
b.ToTable("authorization_scope_versions", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_authorization_scope_versions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -18825,6 +18952,15 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_audit_logs_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.AuthorizationScopeVersion", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_authorization_scope_versions_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)
|
||||
|
||||
@@ -173,6 +173,8 @@ public sealed class TikuDbContext(
|
||||
public DbSet<PlatformBackendRolePermission> PlatformBackendRolePermissions => Set<PlatformBackendRolePermission>();
|
||||
public DbSet<PlatformBackendRoleMenu> PlatformBackendRoleMenus => Set<PlatformBackendRoleMenu>();
|
||||
public DbSet<PlatformBackendUserRole> PlatformBackendUserRoles => Set<PlatformBackendUserRole>();
|
||||
public DbSet<AuthorizationScopeVersion> AuthorizationScopeVersions => Set<AuthorizationScopeVersion>();
|
||||
public DbSet<AuthorizationCacheInvalidation> AuthorizationCacheInvalidations => Set<AuthorizationCacheInvalidation>();
|
||||
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
|
||||
public DbSet<TenantLifecycleOperation> TenantLifecycleOperations => Set<TenantLifecycleOperation>();
|
||||
public DbSet<WorkerHeartbeat> WorkerHeartbeats => Set<WorkerHeartbeat>();
|
||||
|
||||
@@ -176,6 +176,11 @@ internal sealed class PlatformAdminService(
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantId = tenant.Id
|
||||
});
|
||||
var owner = new User
|
||||
{
|
||||
Email = ownerEmail,
|
||||
@@ -250,6 +255,8 @@ internal sealed class PlatformAdminService(
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await provider.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
||||
.InvalidateAsync(tenant.Id, cancellationToken);
|
||||
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
|
||||
.InvalidateTenantAsync(tenant.Id, cancellationToken);
|
||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSaasSubscriptions
|
||||
.Where(subscription => subscription.TenantId == tenant.Id)
|
||||
@@ -379,7 +386,7 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff upsert", async dbContext =>
|
||||
return await ExecuteSystemAsync("platform staff upsert", async (provider, dbContext) =>
|
||||
{
|
||||
var user = command.UserId.HasValue
|
||||
? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken)
|
||||
@@ -431,6 +438,9 @@ internal sealed class PlatformAdminService(
|
||||
}));
|
||||
AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var invalidator = provider.GetRequiredService<IAuthorizationStateInvalidator>();
|
||||
await invalidator.InvalidateUserAsync(user.Id, cancellationToken);
|
||||
await invalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
|
||||
var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking()
|
||||
.Where(role => roleIds.Contains(role.Id))
|
||||
.Select(role => role.Code)
|
||||
@@ -445,7 +455,7 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff status update", async dbContext =>
|
||||
return await ExecuteSystemAsync("platform staff status update", async (provider, dbContext) =>
|
||||
{
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found");
|
||||
@@ -453,6 +463,8 @@ internal sealed class PlatformAdminService(
|
||||
user.Status = command.Status;
|
||||
AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
|
||||
.InvalidateUserAsync(user.Id, cancellationToken);
|
||||
var roleCodes = await (
|
||||
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class AuthorizationCacheInvalidationProcessor(
|
||||
TikuDbContext dbContext,
|
||||
IAccessSecurityCache cache) : IAuthorizationCacheInvalidationProcessor
|
||||
{
|
||||
public async Task<int> ProcessPendingAsync(int batchSize = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!cache.IsConfigured)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
var items = await dbContext.AuthorizationCacheInvalidations
|
||||
.Where(item => item.ProcessedAt == null)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(batchSize, 1, 500))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var processed = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (item.TargetType)
|
||||
{
|
||||
case "session" when item.SessionId.HasValue:
|
||||
await cache.InvalidateSessionAsync(item.SessionId.Value, cancellationToken);
|
||||
break;
|
||||
case "user" when item.UserId.HasValue:
|
||||
await cache.InvalidateUserAsync(item.UserId.Value, cancellationToken);
|
||||
break;
|
||||
case "tenant" when item.TenantId.HasValue:
|
||||
await cache.InvalidateTenantAsync(item.TenantId.Value, cancellationToken);
|
||||
break;
|
||||
case "membership" when item.TenantId.HasValue && item.UserId.HasValue:
|
||||
await cache.InvalidateMembershipAsync(item.TenantId.Value, item.UserId.Value, cancellationToken);
|
||||
break;
|
||||
case "scope" when item.Realm.HasValue && item.Version.HasValue:
|
||||
await cache.SetAuthorizationVersionAsync(item.Realm.Value, item.TenantId, item.Version.Value, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Invalid authorization cache invalidation {item.Id}.");
|
||||
}
|
||||
item.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
item.LastError = null;
|
||||
item.AttemptCount++;
|
||||
processed++;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
item.AttemptCount++;
|
||||
item.LastError = exception.Message;
|
||||
}
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
29
Tiku.Infrastructure/Security/AuthorizationCacheTelemetry.cs
Normal file
29
Tiku.Infrastructure/Security/AuthorizationCacheTelemetry.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
public static class AuthorizationCacheTelemetry
|
||||
{
|
||||
public const string MeterName = "Tiku.Security.AuthorizationCache";
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
private static readonly Counter<long> ReadCounter = Meter.CreateCounter<long>("tiku.authorization_cache.reads");
|
||||
private static readonly Counter<long> FallbackCounter = Meter.CreateCounter<long>("tiku.authorization_cache.postgres_fallbacks");
|
||||
private static readonly Counter<long> InvalidationCounter = Meter.CreateCounter<long>("tiku.authorization_cache.invalidations");
|
||||
private static readonly Counter<long> VersionMismatchCounter = Meter.CreateCounter<long>("tiku.authorization_cache.version_mismatches");
|
||||
private static readonly Counter<long> ShadowMismatchCounter = Meter.CreateCounter<long>("tiku.authorization_cache.shadow_mismatches");
|
||||
private static readonly Histogram<double> RedisDuration = Meter.CreateHistogram<double>("tiku.authorization_cache.redis.duration", "ms");
|
||||
|
||||
public static void Read(string source, bool hit) => ReadCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("source", source),
|
||||
new KeyValuePair<string, object?>("hit", hit));
|
||||
public static void PostgresFallback() => FallbackCounter.Add(1);
|
||||
public static void Invalidated(string target, bool succeeded) => InvalidationCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("target", target),
|
||||
new KeyValuePair<string, object?>("succeeded", succeeded));
|
||||
public static void VersionMismatch() => VersionMismatchCounter.Add(1);
|
||||
public static void ShadowCompared(bool match)
|
||||
{
|
||||
if (!match) ShadowMismatchCounter.Add(1);
|
||||
}
|
||||
public static void RecordRedisDuration(double milliseconds) => RedisDuration.Record(milliseconds);
|
||||
}
|
||||
105
Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs
Normal file
105
Tiku.Infrastructure/Security/AuthorizationStateInvalidator.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class AuthorizationStateInvalidator(
|
||||
TikuDbContext dbContext,
|
||||
IAccessSecurityCache cache) : IAuthorizationStateInvalidator
|
||||
{
|
||||
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync("session", null, null, sessionId, null, null,
|
||||
token => cache.InvalidateSessionAsync(sessionId, token), cancellationToken);
|
||||
|
||||
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync("user", null, userId, null, null, null,
|
||||
token => cache.InvalidateUserAsync(userId, token), cancellationToken);
|
||||
|
||||
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync("tenant", tenantId, null, null, AuthRealm.Tenant, null,
|
||||
token => cache.InvalidateTenantAsync(tenantId, token), cancellationToken);
|
||||
|
||||
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync("membership", tenantId, userId, null, AuthRealm.Tenant, null,
|
||||
token => cache.InvalidateMembershipAsync(tenantId, userId, token), cancellationToken);
|
||||
|
||||
public async Task<long> BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var updated = await dbContext.AuthorizationScopeVersions
|
||||
.Where(item => item.Realm == realm && item.TenantId == tenantId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Version, item => item.Version + 1)
|
||||
.SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (updated == 0)
|
||||
{
|
||||
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
|
||||
{
|
||||
Realm = realm,
|
||||
TenantId = tenantId,
|
||||
Version = 2
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
var version = await dbContext.AuthorizationScopeVersions.AsNoTracking()
|
||||
.Where(item => item.Realm == realm && item.TenantId == tenantId)
|
||||
.Select(item => item.Version)
|
||||
.SingleAsync(cancellationToken);
|
||||
await ExecuteAsync("scope", tenantId, null, null, realm, version,
|
||||
token => cache.SetAuthorizationVersionAsync(realm, tenantId, version, token), cancellationToken);
|
||||
return version;
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(
|
||||
string targetType, Guid? tenantId, Guid? userId, Guid? sessionId,
|
||||
AuthRealm? realm, long? version, Func<CancellationToken, Task> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!cache.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var invalidation = new AuthorizationCacheInvalidation
|
||||
{
|
||||
TargetType = targetType,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
SessionId = sessionId,
|
||||
Realm = realm,
|
||||
Version = version
|
||||
};
|
||||
dbContext.AuthorizationCacheInvalidations.Add(invalidation);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await operation(cancellationToken);
|
||||
invalidation.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
invalidation.AttemptCount++;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
AuthorizationCacheTelemetry.Invalidated(targetType, true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
invalidation.AttemptCount++;
|
||||
invalidation.LastError = exception.Message;
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
AuthorizationCacheTelemetry.Invalidated(targetType, false);
|
||||
if (dbContext.Database.CurrentTransaction is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
throw new AuthorizationSecurityUnavailableException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullAuthorizationStateInvalidator : IAuthorizationStateInvalidator
|
||||
{
|
||||
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task<long> BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default) => Task.FromResult(1L);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
@@ -12,8 +15,12 @@ internal sealed class CurrentAccessContext(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext tenantContext,
|
||||
IRequestSecurityState requestSecurityState,
|
||||
TikuDbContext dbContext) : ICurrentAccessContext
|
||||
TikuDbContext dbContext,
|
||||
IMemoryCache memoryCache,
|
||||
IAuthorizationSnapshotCache snapshotCache,
|
||||
IOptions<AuthorizationCacheOptions> cacheOptions) : ICurrentAccessContext
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task<CurrentAccessSnapshot>>> SnapshotFlights = new();
|
||||
private Task<CurrentAccessSnapshot>? snapshotTask;
|
||||
|
||||
public Task<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default)
|
||||
@@ -34,6 +41,15 @@ internal sealed class CurrentAccessContext(
|
||||
var isValidated = validatedSession is not null &&
|
||||
validatedSession.UserId == userId &&
|
||||
validatedSession.TenantId == tenantContext.TenantId;
|
||||
if (isValidated && cacheOptions.Value.Mode == AuthorizationCacheMode.Active)
|
||||
{
|
||||
return await LoadCachedSnapshotAsync(
|
||||
userId,
|
||||
tenantContext.TenantId,
|
||||
validatedSession!.Realm,
|
||||
validatedSession.AuthorizationVersion,
|
||||
cancellationToken);
|
||||
}
|
||||
if (!isValidated)
|
||||
{
|
||||
var isUserActive = await dbContext.Users.AsNoTracking()
|
||||
@@ -138,6 +154,94 @@ internal sealed class CurrentAccessContext(
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private async Task<CurrentAccessSnapshot> LoadCachedSnapshotAsync(
|
||||
Guid userId, Guid? tenantId, AuthRealm realm, long version, CancellationToken cancellationToken)
|
||||
{
|
||||
var localKey = $"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}";
|
||||
if (memoryCache.TryGetValue<CurrentAccessSnapshot>(localKey, out var local) && local is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.Read("l1_snapshot", true);
|
||||
return local;
|
||||
}
|
||||
AuthorizationCacheTelemetry.Read("l1_snapshot", false);
|
||||
|
||||
try
|
||||
{
|
||||
var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken);
|
||||
if (distributed is not null && distributed.Version == version)
|
||||
{
|
||||
AuthorizationCacheTelemetry.Read("redis_snapshot", true);
|
||||
memoryCache.Set(localKey, distributed.Snapshot,
|
||||
TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
|
||||
return distributed.Snapshot;
|
||||
}
|
||||
if (distributed is not null)
|
||||
{
|
||||
AuthorizationCacheTelemetry.VersionMismatch();
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// A confirmed session may safely fall back to the authorization source of truth.
|
||||
}
|
||||
|
||||
AuthorizationCacheTelemetry.Read("redis_snapshot", false);
|
||||
AuthorizationCacheTelemetry.PostgresFallback();
|
||||
var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy<Task<CurrentAccessSnapshot>>(
|
||||
() => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication));
|
||||
CurrentAccessSnapshot snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = await flight.Value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
SnapshotFlights.TryRemove(new KeyValuePair<string, Lazy<Task<CurrentAccessSnapshot>>>(localKey, flight));
|
||||
}
|
||||
memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
|
||||
try
|
||||
{
|
||||
await snapshotCache.SetAsync(realm, tenantId, userId,
|
||||
new CachedAuthorizationSnapshot(version, snapshot), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// PostgreSQL remains authoritative; a later request can refill Redis.
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async Task<CurrentAccessSnapshot> LoadPermissionSnapshotAsync(
|
||||
Guid userId, Guid? tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId is null)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId, null, true, false, new HashSet<string>(StringComparer.Ordinal),
|
||||
await LoadPlatformPermissionsAsync(userId, cancellationToken), CurrentDataScope.Self);
|
||||
}
|
||||
|
||||
var roles = await (
|
||||
from userRole in dbContext.TenantBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
where userRole.TenantId == tenantId && userRole.UserId == userId && role.Status == BackendRoleStatus.Active
|
||||
select new { role.Id, role.DataScope })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var roleIds = roles.Select(role => role.Id).ToArray();
|
||||
var permissions = roleIds.Length == 0
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking()
|
||||
join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code
|
||||
where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) &&
|
||||
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
|
||||
select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
return new CurrentAccessSnapshot(
|
||||
userId, tenantId, true, true, permissions, new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Merge(roles.Select(role => role.DataScope)));
|
||||
}
|
||||
|
||||
private CurrentAccessSnapshot Empty()
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
|
||||
170
Tiku.Infrastructure/Security/RedisAuthorizationCache.cs
Normal file
170
Tiku.Infrastructure/Security/RedisAuthorizationCache.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class RedisAuthorizationCache(
|
||||
IConnectionMultiplexer connection,
|
||||
IOptions<AuthorizationCacheOptions> options,
|
||||
string environmentName) : IAccessSecurityCache, IAuthorizationSnapshotCache
|
||||
{
|
||||
private const string SetVersionScript = """
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current then
|
||||
local decoded = cjson.decode(current)
|
||||
if tonumber(decoded.version) > tonumber(ARGV[1]) then return 0 end
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
|
||||
return 1
|
||||
""";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly AuthorizationCacheOptions options = options.Value;
|
||||
private readonly string prefix = $"tiku:{Normalize(environmentName)}";
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public async Task<AccessSecurityCacheState?> GetAsync(
|
||||
AccessSecurityCacheLookup lookup,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var keys = lookup.Realm == AuthRealm.Tenant
|
||||
? new RedisKey[]
|
||||
{
|
||||
SessionKey(lookup.SessionId), UserKey(lookup.UserId), VersionKey(lookup.Realm, lookup.TenantId),
|
||||
TenantKey(lookup.TenantId!.Value), MembershipKey(lookup.TenantId.Value, lookup.UserId)
|
||||
}
|
||||
: new RedisKey[]
|
||||
{
|
||||
SessionKey(lookup.SessionId), UserKey(lookup.UserId), VersionKey(lookup.Realm, null),
|
||||
PlatformAccessKey(lookup.UserId)
|
||||
};
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var values = await connection.GetDatabase().StringGetAsync(keys).WaitAsync(cancellationToken);
|
||||
AuthorizationCacheTelemetry.RecordRedisDuration(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||
var state = new AccessSecurityCacheState(
|
||||
Deserialize<CachedSessionSecurityState>(values[0]),
|
||||
Deserialize<CachedUserSecurityState>(values[1]),
|
||||
lookup.Realm == AuthRealm.Tenant ? Deserialize<CachedTenantSecurityState>(values[3]) : null,
|
||||
lookup.Realm == AuthRealm.Tenant ? Deserialize<CachedMembershipSecurityState>(values[4]) : null,
|
||||
lookup.Realm == AuthRealm.Platform ? Deserialize<CachedPlatformAccessState>(values[3]) : null,
|
||||
Deserialize<CachedAuthorizationVersion>(values[2]));
|
||||
AuthorizationCacheTelemetry.Read("redis_state", state.Complete);
|
||||
return state.Complete ? state : null;
|
||||
}
|
||||
|
||||
public async Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ttl = StateTtl();
|
||||
var database = connection.GetDatabase();
|
||||
var writes = new List<Task>();
|
||||
Add(writes, database, state.Session is null ? default : SessionKey(state.Session.SessionId), state.Session, ttl);
|
||||
Add(writes, database, state.User is null ? default : UserKey(state.User.UserId), state.User, ttl);
|
||||
Add(writes, database, state.Tenant is null ? default : TenantKey(state.Tenant.TenantId), state.Tenant, ttl);
|
||||
Add(writes, database, state.Membership is null ? default : MembershipKey(state.Membership.TenantId, state.Membership.UserId), state.Membership, ttl);
|
||||
Add(writes, database, state.PlatformAccess is null ? default : PlatformAccessKey(state.PlatformAccess.UserId), state.PlatformAccess, ttl);
|
||||
await Task.WhenAll(writes).WaitAsync(cancellationToken);
|
||||
if (state.AuthorizationVersion is { } version)
|
||||
{
|
||||
await SetAuthorizationVersionAsync(
|
||||
version.Realm, version.TenantId, version.Version, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||
DeleteAsync(SessionKey(sessionId), cancellationToken);
|
||||
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
DeleteAsync(UserKey(userId), cancellationToken);
|
||||
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
||||
DeleteAsync(TenantKey(tenantId), cancellationToken);
|
||||
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
DeleteAsync(MembershipKey(tenantId, userId), cancellationToken);
|
||||
|
||||
public async Task SetAuthorizationVersionAsync(
|
||||
AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var value = new CachedAuthorizationVersion(realm, tenantId, version);
|
||||
var ttl = StateTtl();
|
||||
await connection.GetDatabase().ScriptEvaluateAsync(
|
||||
SetVersionScript,
|
||||
[VersionKey(realm, tenantId)],
|
||||
[version, JsonSerializer.Serialize(value, SerializerOptions), (long)ttl.TotalMilliseconds])
|
||||
.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
async Task<CachedAuthorizationSnapshot?> IAuthorizationSnapshotCache.GetAsync(
|
||||
AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await connection.GetDatabase().StringGetAsync(SnapshotKey(realm, tenantId, userId))
|
||||
.WaitAsync(cancellationToken);
|
||||
return Deserialize<CachedAuthorizationSnapshot>(value);
|
||||
}
|
||||
|
||||
Task IAuthorizationSnapshotCache.SetAsync(
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
Guid userId,
|
||||
CachedAuthorizationSnapshot snapshot,
|
||||
CancellationToken cancellationToken) =>
|
||||
SetValueAsync(
|
||||
SnapshotKey(realm, tenantId, userId),
|
||||
snapshot,
|
||||
TimeSpan.FromSeconds(Math.Max(1, options.DistributedSnapshotSeconds)),
|
||||
cancellationToken);
|
||||
|
||||
private async Task DeleteAsync(RedisKey key, CancellationToken cancellationToken) =>
|
||||
await connection.GetDatabase().KeyDeleteAsync(key).WaitAsync(cancellationToken);
|
||||
|
||||
private async Task SetValueAsync<T>(RedisKey key, T value, TimeSpan ttl, CancellationToken cancellationToken) =>
|
||||
await connection.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl)
|
||||
.WaitAsync(cancellationToken);
|
||||
|
||||
private static void Add<T>(List<Task> writes, IDatabase database, RedisKey key, T? value, TimeSpan ttl)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
writes.Add(database.StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl));
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan StateTtl()
|
||||
{
|
||||
var seconds = Math.Max(1, options.DistributedStateSeconds);
|
||||
var jitter = Math.Clamp(options.JitterPercent, 0, 50);
|
||||
return TimeSpan.FromSeconds(seconds * (1 + Random.Shared.Next(-jitter, jitter + 1) / 100d));
|
||||
}
|
||||
|
||||
private static T? Deserialize<T>(RedisValue value) => value.IsNullOrEmpty
|
||||
? default
|
||||
: JsonSerializer.Deserialize<T>(value.ToString(), SerializerOptions);
|
||||
|
||||
private RedisKey SessionKey(Guid id) => $"{prefix}:auth:session:v1:{id:N}";
|
||||
private RedisKey UserKey(Guid id) => $"{prefix}:auth:user:v1:{id:N}";
|
||||
private RedisKey TenantKey(Guid id) => $"{prefix}:auth:tenant:v1:{id:N}";
|
||||
private RedisKey MembershipKey(Guid tenantId, Guid userId) => $"{prefix}:auth:membership:v1:{tenantId:N}:{userId:N}";
|
||||
private RedisKey PlatformAccessKey(Guid userId) => $"{prefix}:auth:platform-access:v1:{userId:N}";
|
||||
private RedisKey VersionKey(AuthRealm realm, Guid? tenantId) =>
|
||||
$"{prefix}:authz:version:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}";
|
||||
private RedisKey SnapshotKey(AuthRealm realm, Guid? tenantId, Guid userId) =>
|
||||
$"{prefix}:authz:snapshot:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}";
|
||||
private static string Normalize(string value) => new(value.Trim().ToLowerInvariant()
|
||||
.Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-').ToArray());
|
||||
}
|
||||
|
||||
internal sealed class NullAuthorizationCache : IAccessSecurityCache, IAuthorizationSnapshotCache
|
||||
{
|
||||
public bool IsConfigured => false;
|
||||
public Task<AccessSecurityCacheState?> GetAsync(AccessSecurityCacheLookup lookup, CancellationToken cancellationToken = default) => Task.FromResult<AccessSecurityCacheState?>(null);
|
||||
public Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task<CachedAuthorizationSnapshot?> GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult<CachedAuthorizationSnapshot?>(null);
|
||||
public Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
16
Tiku.Infrastructure/Security/RequestAccessValidator.cs
Normal file
16
Tiku.Infrastructure/Security/RequestAccessValidator.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class RequestAccessValidator(IAuthSessionStore sessionStore) : IRequestAccessValidator
|
||||
{
|
||||
public Task<AuthSessionValidationResult?> ValidateAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
sessionStore.ValidateAccessSessionAsync(sessionId, userId, realm, tenantId, cancellationToken);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ internal sealed class TenantLifecycleService(
|
||||
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
|
||||
ITenantPublicCacheInvalidator publicCacheInvalidator,
|
||||
ITenantFeatureCacheInvalidator featureCacheInvalidator,
|
||||
IAuthorizationStateInvalidator authorizationStateInvalidator,
|
||||
IObjectStorageService objectStorageService) : ITenantLifecycleService
|
||||
{
|
||||
private static readonly TimeSpan RecentExportWindow = TimeSpan.FromHours(24);
|
||||
@@ -219,6 +220,12 @@ internal sealed class TenantLifecycleService(
|
||||
AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
|
||||
if (previousOwnerId.HasValue)
|
||||
{
|
||||
await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, previousOwnerId.Value, cancellationToken);
|
||||
}
|
||||
await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, targetUserId, cancellationToken);
|
||||
await InvalidateAsync(tenantId, cancellationToken);
|
||||
return ToItem(operation);
|
||||
}
|
||||
@@ -256,6 +263,7 @@ internal sealed class TenantLifecycleService(
|
||||
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
await featureCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
await authorizationStateInvalidator.InvalidateTenantAsync(tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
private static TenantLifecycleOperation CreateOperation(
|
||||
|
||||
@@ -29,7 +29,8 @@ public sealed class TenantAdminDirectService(
|
||||
INotificationProvider notificationProvider,
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore,
|
||||
IFeatureAccessService featureAccessService) : ITenantAdminDirectService
|
||||
IFeatureAccessService featureAccessService,
|
||||
IAuthorizationStateInvalidator authorizationStateInvalidator) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
@@ -621,6 +622,8 @@ public sealed class TenantAdminDirectService(
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
await authorizationStateInvalidator.InvalidateMembershipAsync(
|
||||
actor.TenantId, membership.UserId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminStudentStatusItem>(
|
||||
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
|
||||
}
|
||||
@@ -1282,6 +1285,9 @@ public sealed class TenantAdminDirectService(
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
await authorizationStateInvalidator.InvalidateMembershipAsync(
|
||||
actor.TenantId, membership.UserId, cancellationToken);
|
||||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
}
|
||||
|
||||
@@ -1330,6 +1336,8 @@ public sealed class TenantAdminDirectService(
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
await authorizationStateInvalidator.InvalidateMembershipAsync(
|
||||
actor.TenantId, membership.UserId, cancellationToken);
|
||||
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user