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

322 lines
14 KiB
C#

using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class FeatureAccessService(
TikuDbContext dbContext,
ITenantFeatureSnapshotProvider snapshotProvider) : IFeatureAccessService
{
public async Task<FeatureAccessDecision> EvaluateAsync(
Guid tenantId,
string featureCode,
FeatureAccessOperation operation,
CancellationToken cancellationToken = default)
{
return (await snapshotProvider.GetAsync(tenantId, operation, cancellationToken))
.Evaluate(featureCode, operation);
}
public async Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
Guid tenantId,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default)
{
var snapshot = await snapshotProvider.GetAsync(tenantId, operation, cancellationToken);
return snapshot.Features
.Where(feature => snapshot.Evaluate(feature.Code, operation).Allowed)
.Select(feature => feature.Code)
.ToHashSet(StringComparer.Ordinal);
}
public async Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
Guid tenantId,
IEnumerable<string> permissionCodes,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default)
{
var requested = permissionCodes.Distinct(StringComparer.Ordinal).ToArray();
var permissions = await (
from permission in dbContext.BackendPermissions.AsNoTracking()
join module in dbContext.PermissionModules.AsNoTracking()
on permission.PermissionModuleCode equals module.Code
where requested.Contains(permission.Code)
select new { permission.Code, module.RequiredFeatureCode })
.ToArrayAsync(cancellationToken);
var snapshot = await snapshotProvider.GetAsync(tenantId, operation, cancellationToken);
var allowed = new HashSet<string>(StringComparer.Ordinal);
foreach (var permission in permissions)
{
if (permission.RequiredFeatureCode is null ||
snapshot.Evaluate(permission.RequiredFeatureCode, operation).Allowed)
{
allowed.Add(permission.Code);
}
}
return allowed;
}
public async Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var rows = await dbContext.Database.SqlQuery<QuotaSummaryProjection>($"""
WITH current_subscription AS (
SELECT subscription.id,
subscription.base_offering_version_id,
subscription.current_period_start,
subscription.current_period_end
FROM tenant_saas_subscriptions AS subscription
WHERE subscription.tenant_id = {tenantId}
AND subscription.status IN ('trial', 'active')
AND subscription.starts_at <= {now}
AND subscription.current_period_end > {now}
ORDER BY subscription.updated_at DESC
LIMIT 1
),
version_ids AS (
SELECT subscription.base_offering_version_id AS offering_version_id
FROM current_subscription AS subscription
UNION
SELECT item.offering_version_id
FROM tenant_saas_subscription_items AS item
INNER JOIN current_subscription AS subscription
ON subscription.id = item.subscription_id
WHERE item.tenant_id = {tenantId}
AND item.status = 'active'
AND item.starts_at <= {now}
AND item.ends_at > {now}
),
quota_limits AS (
SELECT definition.metric_code,
sum(definition.limit_value)::bigint AS limit_value
FROM saas_offering_version_limits AS definition
WHERE definition.offering_version_id IN (
SELECT version.offering_version_id FROM version_ids AS version)
GROUP BY definition.metric_code
)
SELECT limits.metric_code AS "MetricCode",
COALESCE(usage.used_value, 0)::bigint AS "Used",
limits.limit_value AS "Limit",
COALESCE(usage.period_start, subscription.current_period_start) AS "PeriodStart",
COALESCE(usage.period_end, subscription.current_period_end) AS "PeriodEnd"
FROM quota_limits AS limits
CROSS JOIN current_subscription AS subscription
LEFT JOIN LATERAL (
SELECT current_usage.used_value,
current_usage.period_start,
current_usage.period_end
FROM tenant_feature_usage AS current_usage
WHERE current_usage.tenant_id = {tenantId}
AND current_usage.metric_code = limits.metric_code
AND current_usage.period_start <= {now}
AND current_usage.period_end > {now}
ORDER BY current_usage.period_start DESC
LIMIT 1
) AS usage ON TRUE
ORDER BY limits.metric_code
""")
.ToArrayAsync(cancellationToken);
var result = new List<FeatureQuotaSnapshot>(rows.Length);
foreach (var row in rows)
{
var percent = row.Limit == 0 ? 100 : (int)Math.Min(100, row.Used * 100 / row.Limit);
result.Add(new FeatureQuotaSnapshot(
row.MetricCode,
row.Used,
row.Limit,
percent,
percent >= 80,
row.Used >= row.Limit,
row.PeriodStart,
row.PeriodEnd));
}
return result;
}
public async Task<bool> TryConsumeQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
var normalized = Normalize(metricCode);
var now = DateTimeOffset.UtcNow;
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
if (subscription is null)
{
return false;
}
var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, cancellationToken);
if (!limits.TryGetValue(normalized, out var limit))
{
// Quotas are opt-in per offering version. A missing metric means that the
// subscription does not cap this operation, rather than a zero allowance.
return true;
}
var updated = await dbContext.TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart &&
value.PeriodEnd == subscription.CurrentPeriodEnd &&
value.UsedValue + amount <= limit)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.UsedValue, value => value.UsedValue + amount)
.SetProperty(value => value.LimitValueSnapshot, limit)
.SetProperty(value => value.WarningIssued, value => value.WarningIssued || (value.UsedValue + amount) * 100 >= limit * 80)
.SetProperty(value => value.Version, value => value.Version + 1)
.SetProperty(value => value.UpdatedAt, now), cancellationToken);
if (updated == 1)
{
return true;
}
var exists = await dbContext.TenantFeatureUsages.AnyAsync(value =>
value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd,
cancellationToken);
if (exists || amount > limit)
{
return false;
}
dbContext.TenantFeatureUsages.Add(new TenantFeatureUsage
{
TenantId = tenantId,
MetricCode = normalized,
PeriodStart = subscription.CurrentPeriodStart,
PeriodEnd = subscription.CurrentPeriodEnd,
UsedValue = amount,
LimitValueSnapshot = limit,
WarningIssued = amount * 100 >= limit * 80,
Version = 1
});
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
catch (DbUpdateException exception)
{
foreach (var entry in exception.Entries)
{
entry.State = EntityState.Detached;
}
return await dbContext.TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart &&
value.PeriodEnd == subscription.CurrentPeriodEnd &&
value.UsedValue + amount <= limit)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.UsedValue, value => value.UsedValue + amount)
.SetProperty(value => value.Version, value => value.Version + 1)
.SetProperty(value => value.UpdatedAt, now), cancellationToken) == 1;
}
}
public async Task ReleaseQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
var normalized = Normalize(metricCode);
var now = DateTimeOffset.UtcNow;
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
if (subscription is null)
{
return;
}
await dbContext.TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart &&
value.PeriodEnd == subscription.CurrentPeriodEnd)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.UsedValue, value => value.UsedValue > amount ? value.UsedValue - amount : 0)
.SetProperty(value => value.Version, value => value.Version + 1)
.SetProperty(value => value.UpdatedAt, now), cancellationToken);
}
private async Task<SubscriptionProjection?> CurrentWritableSubscriptionAsync(
Guid tenantId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
return await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenantId &&
(value.Status == TenantSaasSubscriptionStatus.Trial ||
value.Status == TenantSaasSubscriptionStatus.Active))
.Where(value => value.StartsAt <= now && value.CurrentPeriodEnd > now)
.OrderByDescending(value => value.UpdatedAt)
.Select(value => new SubscriptionProjection(
value.Id,
value.BaseOfferingVersionId,
value.CurrentPeriodStart,
value.CurrentPeriodEnd))
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<Dictionary<string, long>> ResolveLimitsAsync(
Guid tenantId,
Guid subscriptionId,
Guid baseVersionId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscriptionId &&
value.Status == TenantSaasSubscriptionItemStatus.Active &&
value.StartsAt <= now && value.EndsAt > now)
.Select(value => value.OfferingVersionId)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(baseVersionId))
{
versionIds = [.. versionIds, baseVersionId];
}
return await dbContext.SaasOfferingVersionLimits.AsNoTracking()
.Where(value => versionIds.Contains(value.OfferingVersionId))
.GroupBy(value => value.MetricCode)
.Select(group => new { MetricCode = group.Key, Limit = group.Sum(value => value.LimitValue) })
.ToDictionaryAsync(value => value.MetricCode, value => value.Limit, StringComparer.Ordinal, cancellationToken);
}
private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) =>
new(true, null, featureCode, operation);
private static FeatureAccessDecision Denied(string featureCode, FeatureAccessOperation operation, string code) =>
new(false, code, featureCode, operation);
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
private sealed record SubscriptionProjection(
Guid Id,
Guid BaseOfferingVersionId,
DateTimeOffset CurrentPeriodStart,
DateTimeOffset CurrentPeriodEnd);
private sealed record QuotaSummaryProjection(
string MetricCode,
long Used,
long Limit,
DateTimeOffset PeriodStart,
DateTimeOffset PeriodEnd);
}