302 lines
18 KiB
C#
302 lines
18 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Security;
|
|
|
|
internal sealed class FeatureAccessService(
|
|
IPlatformControlPlanePersistence platformControlPlanePersistence,
|
|
IJobsOperationsPersistence jobsOperationsPersistence,
|
|
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 jobsOperationsPersistence.BackendPermissions.AsNoTracking()
|
|
join module in platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.TenantFeatureUsages.AnyAsync(value =>
|
|
value.TenantId == tenantId && value.MetricCode == normalized &&
|
|
value.PeriodStart == subscription.CurrentPeriodStart &&
|
|
value.PeriodEnd == subscription.CurrentPeriodEnd,
|
|
cancellationToken);
|
|
if (exists || amount > limit) return false;
|
|
|
|
platformControlPlanePersistence.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 platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
return true;
|
|
}
|
|
catch (DbUpdateException exception)
|
|
{
|
|
foreach (var entry in exception.Entries) entry.State = EntityState.Detached;
|
|
return await platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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)
|
|
{
|
|
return new FeatureAccessDecision(true, null, featureCode, operation);
|
|
}
|
|
|
|
private static FeatureAccessDecision Denied(string featureCode, FeatureAccessOperation operation, string code)
|
|
{
|
|
return new FeatureAccessDecision(false, code, featureCode, operation);
|
|
}
|
|
|
|
private static string Normalize(string value)
|
|
{
|
|
return 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);
|
|
} |