feat(auth): add Redis authorization caching

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

View File

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