Files
tiku-backend.net/Tiku.Infrastructure/Security/RedisAuthorizationCache.cs

171 lines
9.5 KiB
C#

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