171 lines
9.5 KiB
C#
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;
|
|
}
|