252 lines
11 KiB
C#
252 lines
11 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Security;
|
|
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<AuthorizationCacheOptions> cacheOptions) : ICurrentAccessContext
|
|
{
|
|
private static readonly ConcurrentDictionary<string, Lazy<Task<CurrentAccessSnapshot>>> SnapshotFlights = new();
|
|
private Task<CurrentAccessSnapshot>? snapshotTask;
|
|
|
|
public Task<CurrentAccessSnapshot> 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<CurrentAccessSnapshot> 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<string>(StringComparer.Ordinal),
|
|
new HashSet<string>(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<string>(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<string>(StringComparer.Ordinal),
|
|
new HashSet<string>(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<string>(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<string>(StringComparer.Ordinal),
|
|
CurrentDataScope.Merge(tenantRoles.Select(role => role.DataScope)));
|
|
}
|
|
|
|
private async Task<HashSet<string>> 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<CurrentAccessSnapshot> 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<CurrentAccessSnapshot>(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<Task<CurrentAccessSnapshot>>(
|
|
() => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None),
|
|
LazyThreadSafetyMode.ExecutionAndPublication));
|
|
CurrentAccessSnapshot snapshot;
|
|
try
|
|
{
|
|
snapshot = await flight.Value;
|
|
}
|
|
finally
|
|
{
|
|
SnapshotFlights.TryRemove(new KeyValuePair<string, Lazy<Task<CurrentAccessSnapshot>>>(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<CurrentAccessSnapshot> LoadPermissionSnapshotAsync(
|
|
Guid userId, Guid? tenantId, CancellationToken cancellationToken)
|
|
{
|
|
if (tenantId is null)
|
|
return new CurrentAccessSnapshot(
|
|
userId, null, true, false, new HashSet<string>(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<string>(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<string>(StringComparer.Ordinal),
|
|
CurrentDataScope.Merge(roles.Select(role => role.DataScope)));
|
|
}
|
|
|
|
private CurrentAccessSnapshot Empty()
|
|
{
|
|
return new CurrentAccessSnapshot(
|
|
null,
|
|
tenantContext.TenantId,
|
|
false,
|
|
false,
|
|
new HashSet<string>(StringComparer.Ordinal),
|
|
new HashSet<string>(StringComparer.Ordinal),
|
|
CurrentDataScope.Self);
|
|
}
|
|
} |