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 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 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(values[0]), Deserialize(values[1]), lookup.Realm == AuthRealm.Tenant ? Deserialize(values[3]) : null, lookup.Realm == AuthRealm.Tenant ? Deserialize(values[4]) : null, lookup.Realm == AuthRealm.Platform ? Deserialize(values[3]) : null, Deserialize(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(); 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 IAuthorizationSnapshotCache.GetAsync( AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken) { var value = await connection.GetDatabase().StringGetAsync(SnapshotKey(realm, tenantId, userId)) .WaitAsync(cancellationToken); return Deserialize(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(RedisKey key, T value, TimeSpan ttl, CancellationToken cancellationToken) => await connection.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl) .WaitAsync(cancellationToken); private static void Add(List 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(RedisValue value) => value.IsNullOrEmpty ? default : JsonSerializer.Deserialize(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 GetAsync(AccessSecurityCacheLookup lookup, CancellationToken cancellationToken = default) => Task.FromResult(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 GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, CancellationToken cancellationToken = default) => Task.CompletedTask; }