feat(auth): add Redis authorization caching
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"AuthorizationCache": {
|
||||
"Mode": "Disabled",
|
||||
"LocalSnapshotSeconds": 60,
|
||||
"DistributedStateSeconds": 60,
|
||||
"DistributedSnapshotSeconds": 300,
|
||||
"JitterPercent": 20
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "tiku-backend",
|
||||
"Audience": "tiku-api",
|
||||
|
||||
@@ -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);
|
||||
|
||||
87
Tiku.Application/Security/AuthorizationCacheModels.cs
Normal file
87
Tiku.Application/Security/AuthorizationCacheModels.cs
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
47
Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs
Normal file
47
Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
39
docs/redis-authorization-cache.md
Normal file
39
docs/redis-authorization-cache.md
Normal 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 失效。
|
||||
@@ -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 响应不暴露内部依赖明细。
|
||||
|
||||
## 一键本机测试
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user