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; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Security; internal sealed class CurrentAccessContext( ICurrentUser currentUser, ITenantContext tenantContext, IRequestSecurityState requestSecurityState, TikuDbContext dbContext, IMemoryCache memoryCache, IAuthorizationSnapshotCache snapshotCache, IOptions cacheOptions) : ICurrentAccessContext { private static readonly ConcurrentDictionary>> SnapshotFlights = new(); private Task? snapshotTask; public Task GetAsync(CancellationToken cancellationToken = default) { // The context is scoped to one request. Do not allow an aborted authorization // check to poison the cached access snapshot used later in that request. return snapshotTask ??= LoadAsync(CancellationToken.None); } private async Task LoadAsync(CancellationToken cancellationToken) { if (!currentUser.IsAuthenticated || currentUser.UserId is not { } userId) { return Empty(); } var validatedSession = requestSecurityState.ValidatedSession; 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() .AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken); if (!isUserActive) { return new CurrentAccessSnapshot( userId, tenantContext.TenantId, false, false, new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); } } if (tenantContext.TenantId is not { } tenantId) { var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken); return new CurrentAccessSnapshot( userId, null, true, false, new HashSet(StringComparer.Ordinal), platformPermissions, CurrentDataScope.Self); } if (!isValidated) { var isTenantActive = await dbContext.Tenants.AsNoTracking() .AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken); var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking() .AnyAsync( membership => membership.TenantId == tenantId && membership.UserId == userId && membership.Status == MembershipStatus.Active, cancellationToken); if (!isActiveMember) { return new CurrentAccessSnapshot( userId, tenantId, true, false, new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); } } var tenantRoles = 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 = tenantRoles.Select(role => role.Id).ToArray(); var tenantPermissions = roleIds.Length == 0 ? new HashSet(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, tenantPermissions, new HashSet(StringComparer.Ordinal), CurrentDataScope.Merge(tenantRoles.Select(role => role.DataScope))); } private async Task> LoadPlatformPermissionsAsync(Guid userId, CancellationToken cancellationToken) { return (await ( from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id join binding in dbContext.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding.RoleId join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code where userRole.UserId == userId && role.Status == BackendRoleStatus.Active && (permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both) select binding.PermissionCode) .Distinct() .ToArrayAsync(cancellationToken)) .ToHashSet(StringComparer.Ordinal); } private async Task 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(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>( () => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None), LazyThreadSafetyMode.ExecutionAndPublication)); CurrentAccessSnapshot snapshot; try { snapshot = await flight.Value; } finally { SnapshotFlights.TryRemove(new KeyValuePair>>(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 LoadPermissionSnapshotAsync( Guid userId, Guid? tenantId, CancellationToken cancellationToken) { if (tenantId is null) { return new CurrentAccessSnapshot( userId, null, true, false, new HashSet(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(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(StringComparer.Ordinal), CurrentDataScope.Merge(roles.Select(role => role.DataScope))); } private CurrentAccessSnapshot Empty() { return new CurrentAccessSnapshot( null, tenantContext.TenantId, false, false, new HashSet(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); } }