365 lines
16 KiB
C#
365 lines
16 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) : IFeatureAccessService
|
|
{
|
|
public async Task<FeatureAccessDecision> EvaluateAsync(
|
|
Guid tenantId,
|
|
string featureCode,
|
|
FeatureAccessOperation operation,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalized = Normalize(featureCode);
|
|
var tenantStatus = await dbContext.Tenants.AsNoTracking()
|
|
.Where(value => value.Id == tenantId)
|
|
.Select(value => (TenantStatus?)value.Status)
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (tenantStatus != TenantStatus.Active)
|
|
{
|
|
return Denied(normalized, operation, "tenant_inactive");
|
|
}
|
|
|
|
var feature = await dbContext.SaasFeatures.AsNoTracking()
|
|
.Where(value => value.Code == normalized)
|
|
.Select(value => new { value.Status, value.IsCore })
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (feature is null || feature.Status != SaasFeatureStatus.Active)
|
|
{
|
|
return Denied(normalized, operation, "feature_unavailable");
|
|
}
|
|
|
|
if (feature.IsCore)
|
|
{
|
|
return Allowed(normalized, operation);
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var overrideMode = await dbContext.TenantFeatureOverrides.AsNoTracking()
|
|
.Where(value => value.TenantId == tenantId && value.FeatureCode == normalized &&
|
|
(value.ExpiresAt == null || value.ExpiresAt > now))
|
|
.Select(value => (TenantFeatureOverrideMode?)value.Mode)
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (overrideMode == TenantFeatureOverrideMode.Disabled)
|
|
{
|
|
return Denied(normalized, operation, "feature_disabled");
|
|
}
|
|
|
|
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
|
|
.Where(value => value.TenantId == tenantId)
|
|
.OrderByDescending(value => value.UpdatedAt)
|
|
.Select(value => new
|
|
{
|
|
value.Id,
|
|
value.BaseOfferingVersionId,
|
|
value.Status,
|
|
value.StartsAt,
|
|
value.CurrentPeriodEnd
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (subscription is null)
|
|
{
|
|
return overrideMode == TenantFeatureOverrideMode.Enabled
|
|
? Allowed(normalized, operation)
|
|
: Denied(normalized, operation, "subscription_missing");
|
|
}
|
|
|
|
if (operation == FeatureAccessOperation.Write &&
|
|
(subscription.Status is not (TenantSaasSubscriptionStatus.Trial or TenantSaasSubscriptionStatus.Active) ||
|
|
subscription.StartsAt > now || subscription.CurrentPeriodEnd <= now))
|
|
{
|
|
return Denied(normalized, operation, "subscription_read_only");
|
|
}
|
|
|
|
if (subscription.Status == TenantSaasSubscriptionStatus.Suspended)
|
|
{
|
|
return Denied(normalized, operation, "subscription_suspended");
|
|
}
|
|
|
|
if (overrideMode == TenantFeatureOverrideMode.Enabled)
|
|
{
|
|
return Allowed(normalized, operation);
|
|
}
|
|
|
|
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
|
|
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id &&
|
|
(operation == FeatureAccessOperation.Read
|
|
? value.Status != TenantSaasSubscriptionItemStatus.Pending &&
|
|
value.Status != TenantSaasSubscriptionItemStatus.Scheduled &&
|
|
value.StartsAt <= now
|
|
: value.Status == TenantSaasSubscriptionItemStatus.Active &&
|
|
value.StartsAt <= now && value.EndsAt > now))
|
|
.Select(value => value.OfferingVersionId)
|
|
.ToArrayAsync(cancellationToken);
|
|
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
|
|
{
|
|
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
|
|
}
|
|
|
|
var entitled = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
|
|
.AnyAsync(value => versionIds.Contains(value.OfferingVersionId) && value.FeatureCode == normalized, cancellationToken);
|
|
return entitled
|
|
? Allowed(normalized, operation)
|
|
: Denied(normalized, operation, "feature_not_purchased");
|
|
}
|
|
|
|
public async Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
|
|
Guid tenantId,
|
|
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var codes = await dbContext.SaasFeatures.AsNoTracking()
|
|
.Where(value => value.Status == SaasFeatureStatus.Active)
|
|
.Select(value => value.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
var enabled = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var code in codes)
|
|
{
|
|
if ((await EvaluateAsync(tenantId, code, operation, cancellationToken)).Allowed)
|
|
{
|
|
enabled.Add(code);
|
|
}
|
|
}
|
|
|
|
return enabled;
|
|
}
|
|
|
|
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 allowed = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var permission in permissions)
|
|
{
|
|
if (permission.RequiredFeatureCode is null ||
|
|
(await EvaluateAsync(tenantId, permission.RequiredFeatureCode, operation, cancellationToken)).Allowed)
|
|
{
|
|
allowed.Add(permission.Code);
|
|
}
|
|
}
|
|
|
|
return allowed;
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
|
|
if (subscription is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, cancellationToken);
|
|
var usages = await dbContext.TenantFeatureUsages.AsNoTracking()
|
|
.Where(value => value.TenantId == tenantId && value.PeriodStart <= now && value.PeriodEnd > now)
|
|
.ToDictionaryAsync(value => value.MetricCode, StringComparer.Ordinal, cancellationToken);
|
|
var result = new List<FeatureQuotaSnapshot>();
|
|
foreach (var limit in limits)
|
|
{
|
|
usages.TryGetValue(limit.Key, out var usage);
|
|
var used = usage?.UsedValue ?? 0;
|
|
var percent = limit.Value == 0 ? 100 : (int)Math.Min(100, used * 100 / limit.Value);
|
|
result.Add(new FeatureQuotaSnapshot(
|
|
limit.Key,
|
|
used,
|
|
limit.Value,
|
|
percent,
|
|
percent >= 80,
|
|
used >= limit.Value,
|
|
usage?.PeriodStart ?? subscription.CurrentPeriodStart,
|
|
usage?.PeriodEnd ?? subscription.CurrentPeriodEnd));
|
|
}
|
|
|
|
return result.OrderBy(value => value.MetricCode, StringComparer.Ordinal).ToArray();
|
|
}
|
|
|
|
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);
|
|
}
|