forked from xiongyuxing/tiku-backend.net
feat(security): add distributed authorization foundation
This commit is contained in:
87
Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs
Normal file
87
Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapabilityAccessEvaluator
|
||||
{
|
||||
public async Task<bool> IsAllowedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = moduleCode.Trim().ToLowerInvariant();
|
||||
var moduleExists = await dbContext.ProductModules.AsNoTracking()
|
||||
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
|
||||
if (!moduleExists)
|
||||
{
|
||||
// Compatibility while the fixed module catalog is introduced module-by-module.
|
||||
return true;
|
||||
}
|
||||
|
||||
var tenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken);
|
||||
if (!tenantActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var overrideMode = await dbContext.TenantModuleOverrides.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.ModuleCode == normalized &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||
.Select(item => (TenantModuleOverrideMode?)item.Mode)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (overrideMode == TenantModuleOverrideMode.Disabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subscription = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.Select(item => new { item.PlanCode, item.Status, item.StartsAt, item.ExpiresAt })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (subscription is null || subscription.StartsAt > now || subscription.ExpiresAt <= now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entitled = overrideMode == TenantModuleOverrideMode.Enabled ||
|
||||
await dbContext.PlanModuleEntitlements.AsNoTracking().AnyAsync(
|
||||
item => item.PlanCode == subscription.PlanCode && item.ModuleCode == normalized && item.Enabled,
|
||||
cancellationToken);
|
||||
if (!entitled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return operation == CapabilityOperation.Read ||
|
||||
subscription.Status is TenantSubscriptionStatus.Trial or TenantSubscriptionStatus.Active;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||
Guid tenantId,
|
||||
CapabilityOperation operation = CapabilityOperation.Read,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var modules = await dbContext.ProductModules.AsNoTracking()
|
||||
.Where(item => item.Status == ProductModuleStatus.Active)
|
||||
.Select(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var enabled = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (await IsAllowedAsync(tenantId, module, operation, cancellationToken))
|
||||
{
|
||||
enabled.Add(module);
|
||||
}
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
public sealed class RedisSecurityConnectionOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
}
|
||||
135
Tiku.Infrastructure/Security/RedisSecurityStore.cs
Normal file
135
Tiku.Infrastructure/Security/RedisSecurityStore.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class RedisSecurityStore(
|
||||
IConnectionMultiplexer connection,
|
||||
string environmentName,
|
||||
ILogger<RedisSecurityStore> logger) : IRedisSecurityStore
|
||||
{
|
||||
private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0");
|
||||
private static readonly Counter<long> OperationCounter = Meter.CreateCounter<long>("tiku.redis.security.operations");
|
||||
private static readonly Counter<long> RejectionCounter = Meter.CreateCounter<long>("tiku.redis.rate_limit.rejections");
|
||||
private static readonly Counter<long> ErrorCounter = Meter.CreateCounter<long>("tiku.redis.security.errors");
|
||||
private static readonly Histogram<double> ScriptDuration = Meter.CreateHistogram<double>(
|
||||
"tiku.redis.lua.duration", "ms");
|
||||
private const string ConsumeScript = """
|
||||
local now = redis.call('TIME')
|
||||
local nowMs = now[1] * 1000 + math.floor(now[2] / 1000)
|
||||
local retryAfter = 0
|
||||
for i = 1, #KEYS do
|
||||
local current = tonumber(redis.call('GET', KEYS[i]) or '0')
|
||||
local limit = tonumber(ARGV[(i - 1) * 2 + 1])
|
||||
if current >= limit then
|
||||
local ttl = redis.call('PTTL', KEYS[i])
|
||||
if ttl > retryAfter then retryAfter = ttl end
|
||||
end
|
||||
end
|
||||
if retryAfter > 0 then return {0, retryAfter} end
|
||||
for i = 1, #KEYS do
|
||||
local window = tonumber(ARGV[(i - 1) * 2 + 2])
|
||||
local value = redis.call('INCR', KEYS[i])
|
||||
if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end
|
||||
end
|
||||
return {1, 0}
|
||||
""";
|
||||
|
||||
private readonly string prefix = $"tiku:{Normalize(environmentName)}";
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public async Task<DistributedRateLimitResult> ConsumeAsync(
|
||||
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (buckets.Count == 0)
|
||||
{
|
||||
return new DistributedRateLimitResult(true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var keys = buckets.Select(bucket => (RedisKey)$"{prefix}:rl:{bucket.Key}").ToArray();
|
||||
var values = buckets
|
||||
.SelectMany(bucket => new RedisValue[] { bucket.PermitLimit, (long)bucket.Window.TotalMilliseconds })
|
||||
.ToArray();
|
||||
var result = (RedisResult[])(await connection.GetDatabase()
|
||||
.ScriptEvaluateAsync(ConsumeScript, keys, values).WaitAsync(cancellationToken))!;
|
||||
var allowed = (long)result[0] == 1;
|
||||
var retryMs = (long)result[1];
|
||||
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
||||
ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
|
||||
if (!allowed)
|
||||
{
|
||||
RejectionCounter.Add(1);
|
||||
}
|
||||
return new DistributedRateLimitResult(
|
||||
allowed,
|
||||
retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null);
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||
{
|
||||
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "rate_limit"));
|
||||
logger.LogError(exception, "Redis security operation failed closed.");
|
||||
throw new RedisSecurityUnavailableException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.GetDatabase().PingAsync().WaitAsync(cancellationToken);
|
||||
OperationCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||
{
|
||||
ErrorCounter.Add(1, new KeyValuePair<string, object?>("operation", "ping"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{prefix}:auth-inv:{Normalize(realm)}:{tenantId?.ToString("N") ?? "-"}:{userId?.ToString("N") ?? "-"}";
|
||||
await connection.GetDatabase().StringSetAsync(key, version, TimeSpan.FromDays(2)).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
string.Concat(value.Trim().ToLowerInvariant().Select(character =>
|
||||
char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-'));
|
||||
}
|
||||
|
||||
public sealed class NullRedisSecurityStore : IRedisSecurityStore
|
||||
{
|
||||
public bool IsConfigured => false;
|
||||
|
||||
public Task<DistributedRateLimitResult> ConsumeAsync(
|
||||
IReadOnlyCollection<DistributedRateLimitBucket> buckets,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new DistributedRateLimitResult(true));
|
||||
|
||||
public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
|
||||
public Task SetInvalidationVersionAsync(
|
||||
string realm,
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
long version,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed class RedisSecurityUnavailableException(Exception innerException)
|
||||
: Exception("Redis security services are unavailable.", innerException);
|
||||
Reference in New Issue
Block a user