feat(auth): add Redis authorization caching

This commit is contained in:
2026-08-01 12:20:31 +08:00
parent 84c2b0b21d
commit 46abf4d62f
36 changed files with 21757 additions and 35 deletions

View File

@@ -199,8 +199,8 @@ internal static class AuthenticationExtensions
}
}
var sessionStore = context.HttpContext.RequestServices.GetRequiredService<IAuthSessionStore>();
var session = await sessionStore.ValidateAccessSessionAsync(
var accessValidator = context.HttpContext.RequestServices.GetRequiredService<IRequestAccessValidator>();
var session = await accessValidator.ValidateAsync(
sessionId,
userId,
realm.Value,

View File

@@ -39,6 +39,12 @@ public static class DependencyInjection
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
"Redis is required in Production.")
.ValidateOnStart();
builder.Services.AddOptions<AuthorizationCacheOptions>()
.Bind(builder.Configuration.GetSection(AuthorizationCacheOptions.SectionName))
.Validate(options => options.LocalSnapshotSeconds > 0 && options.DistributedStateSeconds > 0 &&
options.DistributedSnapshotSeconds > 0 && options.JitterPercent is >= 0 and <= 50,
"Authorization cache durations must be positive and jitter must be between 0 and 50 percent.")
.ValidateOnStart();
if (!string.IsNullOrWhiteSpace(redisConnectionString))
{
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);

View File

@@ -2,6 +2,7 @@ using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Tiku.Infrastructure.Observability;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Configuration;
@@ -28,7 +29,8 @@ internal static class ObservabilityExtensions
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName,
AuthorizationCacheTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
return services;

View File

@@ -36,6 +36,16 @@ public sealed class ExceptionHandlingMiddleware(
}
catch (Exception exception)
{
if (exception is AuthorizationSecurityUnavailableException)
{
await WriteProblemAsync(
context,
"Authentication security service is unavailable.",
StatusCodes.Status503ServiceUnavailable,
"auth_security_unavailable");
return;
}
if (exception is AuthException authException)
{
await WriteAuthProblemAsync(context, authException);

View File

@@ -127,6 +127,13 @@
}
},
"Security": {
"AuthorizationCache": {
"Mode": "Disabled",
"LocalSnapshotSeconds": 60,
"DistributedStateSeconds": 60,
"DistributedSnapshotSeconds": 300,
"JitterPercent": 20
},
"Jwt": {
"Issuer": "tiku-backend",
"Audience": "tiku-api",

View File

@@ -59,6 +59,6 @@ public sealed record AuthSessionIssueRequest(
Guid? TokenFamilyId = null,
Guid? ParentSessionId = null);
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId);
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, long AuthorizationVersion = 1);
public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId);

View File

@@ -0,0 +1,87 @@
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Application.Auth;
namespace Tiku.Application.Security;
public enum AuthorizationCacheMode { Disabled, Shadow, Active }
public sealed class AuthorizationCacheOptions
{
public const string SectionName = "Security:AuthorizationCache";
public AuthorizationCacheMode Mode { get; set; } = AuthorizationCacheMode.Disabled;
public int LocalSnapshotSeconds { get; set; } = 60;
public int DistributedStateSeconds { get; set; } = 60;
public int DistributedSnapshotSeconds { get; set; } = 300;
public int JitterPercent { get; set; } = 20;
}
public sealed record CachedSessionSecurityState(
Guid SessionId, Guid UserId, AuthRealm Realm, Guid? TenantId,
string SecurityStamp, DateTimeOffset ExpiresAt, bool Revoked);
public sealed record CachedUserSecurityState(Guid UserId, UserStatus Status, string SecurityStamp);
public sealed record CachedTenantSecurityState(Guid TenantId, TenantStatus Status);
public sealed record CachedMembershipSecurityState(Guid TenantId, Guid UserId, MembershipStatus Status);
public sealed record CachedPlatformAccessState(Guid UserId, long AuthorizationVersion, bool Allowed);
public sealed record CachedAuthorizationVersion(AuthRealm Realm, Guid? TenantId, long Version);
public sealed record AccessSecurityCacheLookup(Guid SessionId, Guid UserId, AuthRealm Realm, Guid? TenantId);
public sealed record AccessSecurityCacheState(
CachedSessionSecurityState? Session,
CachedUserSecurityState? User,
CachedTenantSecurityState? Tenant,
CachedMembershipSecurityState? Membership,
CachedPlatformAccessState? PlatformAccess,
CachedAuthorizationVersion? AuthorizationVersion)
{
public bool Complete => Session is not null && User is not null && AuthorizationVersion is not null &&
(Session.Realm == AuthRealm.Platform && PlatformAccess is not null ||
Session.Realm == AuthRealm.Tenant && Tenant is not null && Membership is not null);
}
public sealed record CachedAuthorizationSnapshot(long Version, CurrentAccessSnapshot Snapshot);
public interface IAccessSecurityCache
{
bool IsConfigured { get; }
Task<AccessSecurityCacheState?> GetAsync(AccessSecurityCacheLookup lookup, CancellationToken cancellationToken = default);
Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default);
Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default);
Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default);
Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default);
}
public interface IAuthorizationSnapshotCache
{
Task<CachedAuthorizationSnapshot?> GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken = default);
Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, CancellationToken cancellationToken = default);
}
public interface IAuthorizationStateInvalidator
{
Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default);
Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<long> BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default);
}
public interface IAuthorizationCacheInvalidationProcessor
{
Task<int> ProcessPendingAsync(int batchSize = 100, CancellationToken cancellationToken = default);
}
public interface IRequestAccessValidator
{
Task<AuthSessionValidationResult?> ValidateAsync(
Guid sessionId,
Guid userId,
AuthRealm realm,
Guid? tenantId,
CancellationToken cancellationToken = default);
}
public sealed class AuthorizationSecurityUnavailableException(Exception innerException)
: Exception("Authentication security dependencies are unavailable.", innerException);

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
namespace Tiku.Domain.Operations;
@@ -139,6 +140,26 @@ public sealed class PlatformBackendUserRole : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class AuthorizationScopeVersion : AuditableEntity
{
public AuthRealm Realm { get; set; }
public Guid? TenantId { get; set; }
public long Version { get; set; } = 1;
}
public sealed class AuthorizationCacheInvalidation : AuditableEntity
{
public string TargetType { get; set; } = string.Empty;
public Guid? TenantId { get; set; }
public Guid? UserId { get; set; }
public Guid? SessionId { get; set; }
public AuthRealm? Realm { get; set; }
public long? Version { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
public int AttemptCount { get; set; }
public string? LastError { get; set; }
}
public sealed class BackgroundJob : AuditableTenantEntity
{
public string JobType { get; set; } = string.Empty;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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
});
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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)

View File

@@ -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>();

View File

@@ -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

View File

@@ -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;
}
}

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

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

View File

@@ -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(

View 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;
}

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

View File

@@ -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(

View File

@@ -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));
}

View File

@@ -1,6 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
@@ -10,6 +12,170 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthSessionLifecycleTests
{
[Fact]
public async Task Active_cache_falls_back_to_postgres_when_redis_is_unavailable()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["ConnectionStrings:Redis"] = "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
["Security:AuthorizationCache:Mode"] = "Active"
});
var seed = await SeedActiveMemberAsync(factory);
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using var scope = factory.CreateSystemScope("Validate PostgreSQL authorization fallback");
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
[Fact]
public async Task Active_redis_cache_rejects_disabled_membership_on_the_next_validation()
{
var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
if (string.IsNullOrWhiteSpace(redis))
{
return;
}
var commandRecorder = new RecordingDbCommandInterceptor();
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["ConnectionStrings:Redis"] = redis,
["Security:AuthorizationCache:Mode"] = "Active"
}, dbCommandInterceptor: commandRecorder);
var seed = await SeedActiveMemberAsync(factory);
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using (var scope = factory.CreateSystemScope("Warm Redis access security state"))
{
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
var cached = await scope.ServiceProvider.GetRequiredService<IAccessSecurityCache>()
.GetAsync(new AccessSecurityCacheLookup(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
Assert.True(cached?.Complete);
}
commandRecorder.Reset();
using (var scope = factory.CreateSystemScope("Validate hot Redis access security state"))
{
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
Assert.Empty(commandRecorder.Snapshot());
using (var scope = factory.CreateSystemScope("Disable membership and invalidate Redis"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var membership = await dbContext.TenantMemberships.SingleAsync(item =>
item.TenantId == seed.TenantId && item.UserId == seed.UserId);
membership.Status = MembershipStatus.Disabled;
await dbContext.SaveChangesAsync();
await scope.ServiceProvider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateMembershipAsync(seed.TenantId, seed.UserId);
}
using (var scope = factory.CreateSystemScope("Validate disabled cached membership"))
{
Assert.Null(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
}
[Fact]
public async Task Authorization_version_bypasses_stale_local_permission_snapshot_after_revocation()
{
var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
if (string.IsNullOrWhiteSpace(redis))
{
return;
}
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["ConnectionStrings:Redis"] = redis,
["Security:AuthorizationCache:Mode"] = "Active"
});
var seed = await SeedActiveMemberAsync(factory);
var roleId = Guid.NewGuid();
const string permissionCode = "tenant:auth-cache:manage";
await factory.SeedAsync(new BackendPermission
{
Code = permissionCode,
Name = "Authorization cache test permission",
Area = BackendPermissionArea.Tenant,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(BackendPermissions.TenantSettingsManage)
});
using (var scope = factory.CreateSystemScope("Seed versioned tenant permission"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = seed.TenantId
});
await dbContext.SaveChangesAsync();
dbContext.TenantBackendRoles.Add(new TenantBackendRole
{
Id = roleId,
TenantId = seed.TenantId,
Code = "versioned-cache-role",
Name = "Versioned cache role"
});
dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
{
TenantId = seed.TenantId,
RoleId = roleId,
PermissionCode = permissionCode
});
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = seed.TenantId,
UserId = seed.UserId,
RoleId = roleId
});
await dbContext.SaveChangesAsync();
}
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
AuthSessionValidationResult validated;
using (var scope = factory.CreateSystemScope("Warm session state for permission snapshot"))
{
validated = Assert.IsType<AuthSessionValidationResult>(await scope.ServiceProvider
.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
Assert.True((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode));
using (var scope = factory.CreateSystemScope("Revoke permission and publish durable version"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item =>
item.TenantId == seed.TenantId && item.RoleId == roleId && item.PermissionCode == permissionCode);
dbContext.Remove(binding);
await dbContext.SaveChangesAsync();
await scope.ServiceProvider.GetRequiredService<IAuthorizationCacheInvalidationProcessor>()
.ProcessPendingAsync();
}
using (var scope = factory.CreateSystemScope("Reload session authorization version"))
{
validated = Assert.IsType<AuthSessionValidationResult>(await scope.ServiceProvider
.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
Assert.False((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode));
}
private static async Task<CurrentAccessSnapshot> LoadAccessAsync(
ApiTestFactory factory, SessionSeed seed, AuthSessionValidationResult validated)
{
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.Initialize(seed.TenantId, null, TenantResolutionSource.Jwt);
((CurrentUser)scope.ServiceProvider.GetRequiredService<ICurrentUser>()).Load(new ClaimsPrincipal(
new ClaimsIdentity([new Claim(TikuClaimTypes.UserId, seed.UserId.ToString())], "integration")));
scope.ServiceProvider.GetRequiredService<IRequestSecurityState>().SetValidatedSession(validated);
return await scope.ServiceProvider.GetRequiredService<ICurrentAccessContext>().GetAsync();
}
[Fact]
public async Task Refresh_rotation_creates_a_child_and_replay_revokes_the_entire_family()
{

View File

@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.IntegrationTests.Api;
namespace Tiku.IntegrationTests;
public sealed class AuthorizationCachePersistenceTests
{
[Fact]
public async Task Rbac_mutation_bumps_durable_scope_version_and_enqueues_invalidation_in_same_save()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
using var scope = factory.CreateSystemScope("Verify authorization version trigger");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.Tenants.Add(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Authorization cache trigger tenant"
});
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenantId
});
await dbContext.SaveChangesAsync();
dbContext.TenantBackendRoles.Add(new TenantBackendRole
{
TenantId = tenantId,
Code = "cache-trigger-role",
Name = "Cache trigger role"
});
await dbContext.SaveChangesAsync();
dbContext.ChangeTracker.Clear();
var version = await dbContext.AuthorizationScopeVersions.AsNoTracking()
.SingleAsync(item => item.TenantId == tenantId);
Assert.Equal(2, version.Version);
Assert.True(await dbContext.AuthorizationCacheInvalidations.AsNoTracking().AnyAsync(item =>
item.TargetType == "scope" && item.TenantId == tenantId && item.Version == version.Version));
}
}

View File

@@ -5,6 +5,8 @@ using System.Text;
using Tiku.Application.Security;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
namespace Tiku.IntegrationTests;
@@ -71,10 +73,43 @@ public sealed class RedisSecurityStoreTests
}
}
[Fact]
public async Task Authorization_state_is_shared_and_older_version_cannot_overwrite_newer_version()
{
var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
if (string.IsNullOrWhiteSpace(connectionString))
{
return;
}
var environment = $"integration-authz-{Guid.NewGuid():N}";
await using var first = BuildProvider(connectionString, environment);
await using var second = BuildProvider(connectionString, environment);
var writer = first.GetRequiredService<IAccessSecurityCache>();
var reader = second.GetRequiredService<IAccessSecurityCache>();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var lookup = new AccessSecurityCacheLookup(sessionId, userId, AuthRealm.Tenant, tenantId);
await writer.SetAsync(new AccessSecurityCacheState(
new CachedSessionSecurityState(sessionId, userId, AuthRealm.Tenant, tenantId, "stamp", DateTimeOffset.UtcNow.AddMinutes(15), false),
new CachedUserSecurityState(userId, UserStatus.Active, "stamp"),
new CachedTenantSecurityState(tenantId, TenantStatus.Active),
new CachedMembershipSecurityState(tenantId, userId, MembershipStatus.Active),
null,
new CachedAuthorizationVersion(AuthRealm.Tenant, tenantId, 1)));
Assert.True((await reader.GetAsync(lookup))?.Complete);
await writer.SetAuthorizationVersionAsync(AuthRealm.Tenant, tenantId, 5);
await writer.SetAuthorizationVersionAsync(AuthRealm.Tenant, tenantId, 4);
Assert.Equal(5, (await reader.GetAsync(lookup))?.AuthorizationVersion?.Version);
}
private static ServiceProvider BuildProvider(string connectionString, string environment)
{
var services = new ServiceCollection();
services.AddLogging();
services.Configure<AuthorizationCacheOptions>(_ => { });
services.AddRedisSecurity(connectionString, environment);
return services.BuildServiceProvider();
}

View File

@@ -7,6 +7,7 @@ using Tiku.Infrastructure;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.Observability;
using Tiku.Infrastructure.Security;
using OpenTelemetry.Metrics;
namespace Tiku.Worker;
@@ -29,8 +30,21 @@ internal static class WorkerDependencyInjection
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
builder.Services.AddApplication();
builder.Services.AddInfrastructure(connectionString);
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
builder.Services.AddOptions<AuthorizationCacheOptions>()
.Bind(builder.Configuration.GetSection(AuthorizationCacheOptions.SectionName));
if (!string.IsNullOrWhiteSpace(redisConnectionString))
{
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
}
else if (builder.Environment.IsProduction())
{
throw new InvalidOperationException(
"Redis is required in Production for authorization cache invalidation retries.");
}
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"];
var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(WorkerTelemetry.MeterName));
var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics =>
metrics.AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName));
if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint))
{
telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
@@ -109,6 +123,7 @@ internal static class WorkerDependencyInjection
builder.Services.AddHostedService<SaasSubscriptionWorker>();
builder.Services.AddHostedService<FeatureUsageWorker>();
builder.Services.AddHostedService<BackgroundJobsWorker>();
builder.Services.AddHostedService<AuthorizationCacheInvalidationWorker>();
return builder;
}

View File

@@ -262,3 +262,21 @@ internal sealed class BackgroundJobsWorker(
.ProcessPendingAsync($"{workerId}:{index}", batchSize, includeImmediateJobs: true, cancellationToken: cancellationToken);
}
}
internal sealed class AuthorizationCacheInvalidationWorker(
IServiceScopeFactory scopeFactory,
IPeriodicProcessorLock processorLock,
WorkerStateReporter stateReporter,
IOptions<WorkerOptions> options,
ILogger<AuthorizationCacheInvalidationWorker> logger)
: PeriodicWorker(logger, processorLock, stateReporter, "authorization-cache-invalidations",
TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
{
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
InitializeSystem(scope.ServiceProvider, "Authorization cache invalidation worker");
return await scope.ServiceProvider.GetRequiredService<IAuthorizationCacheInvalidationProcessor>()
.ProcessPendingAsync(cancellationToken: cancellationToken);
}
}

View File

@@ -0,0 +1,39 @@
# Redis 认证授权缓存
## 请求链路
受保护请求先在本地完成 JWT 验签,再通过一次 Redis MGET 校验 Session、用户、租户、成员资格和持久化授权版本。权限快照使用 60 秒进程内缓存和 5 分钟 Redis 缓存本地快照键包含授权版本因此撤权后的下一请求不会继续使用旧权限。PostgreSQL 始终是事实源Redis 读取失败时绕过本地快照并回退数据库。
缓存不保存 JWT、Refresh Token、手机号或邮箱。Redis key 使用环境隔离前缀,并区分 platform/tenant realm、tenant、user 和 session。
## 配置与故障语义
```json
{
"Security": {
"AuthorizationCache": {
"Mode": "Disabled",
"LocalSnapshotSeconds": 60,
"DistributedStateSeconds": 60,
"DistributedSnapshotSeconds": 300,
"JitterPercent": 20
}
}
}
```
- `Disabled`:保持 PostgreSQL 权威读取,仅维护持久化授权版本。
- `Shadow`PostgreSQL 决策仍为准,同时读取、回填和比较 Redis 结果。
- `Active`Redis 为主要读取路径,缓存缺失或不可用时回退 PostgreSQL。
- Redis 与 PostgreSQL 同时不可用时返回 `503`,错误码为 `auth_security_unavailable`
生产环境的 API 和 Worker 都必须配置 `ConnectionStrings:Redis``REDIS_URL`。Worker 重试 `authorization_cache_invalidations` 中未完成的失效事件Redis 版本写入是单调的,旧事件不会覆盖新版本。
## 发布与回滚
1. 先部署迁移,保持 `Disabled`
2. 切换 `Shadow`,观察 `Tiku.Security.AuthorizationCache` 指标中的 mismatch、fallback 和 Redis 延迟。
3. 确认无非并发不一致后,对单实例启用 `Active`,再逐步扩容。
4. 回滚时只把模式切回 `Disabled`,不回退数据库迁移。
RBAC 表和权限目录由 PostgreSQL 触发器在业务事务内推进授权版本并写入失效事件。任何新增的用户、成员、租户、Session 或角色权限写路径,也必须调用 `IAuthorizationStateInvalidator` 完成同步 Redis 失效。

View File

@@ -11,7 +11,7 @@
| `ready` | `GET /api/health/ready` | 每次检查 PostgreSQL、Redis 以及消息/outbox 就绪状态 |
| `mixed` | 70% hot、20% cold、10% ready | 默认的读多型业务流量 |
`setup()` 会强制校验 readiness 响应中的 `database=true``redis.configured=true``redis.ready=true`,所以 Redis 没接上时测试会直接失败,不会给出误导性的容量数字
运行器会先分别执行 PostgreSQL 查询和 Redis `PING`,再要求 API readiness 返回 `status=ready`;任一依赖未接通时测试会直接失败,不会给出误导性的容量数字。公开 readiness 响应不暴露内部依赖明细
## 一键本机测试

View File

@@ -82,12 +82,8 @@ if [[ -z "${ready_body}" ]]; then
exit 1
fi
printf '%s\n' "${ready_body}" >"${result_dir}/readiness.json"
if ! printf '%s' "${ready_body}" | grep -q '"database":true'; then
echo "PostgreSQL is not ready: ${ready_body}" >&2
exit 1
fi
if ! printf '%s' "${ready_body}" | grep -q '"redis":{"configured":true,"ready":true}'; then
echo "Redis is not configured and ready: ${ready_body}" >&2
if ! printf '%s' "${ready_body}" | grep -q '"status":"ready"'; then
echo "PostgreSQL or Redis is not ready: ${ready_body}" >&2
exit 1
fi

View File

@@ -61,8 +61,8 @@ export function setup() {
} catch (error) {
fail(`Readiness endpoint did not return JSON: ${error}`);
}
if (!readiness.database || !readiness.redis?.configured || !readiness.redis?.ready) {
fail(`PostgreSQL and Redis must both be configured and ready: ${JSON.stringify(readiness)}`);
if (readiness.status !== 'ready') {
fail(`PostgreSQL and Redis must both be ready: ${JSON.stringify(readiness)}`);
}
const catalog = http.get(`${baseUrl}/api/catalog/regions`, {