feat(auth): add Redis authorization caching
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user