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 EvaluateAsync( Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) { return (await snapshotProvider.GetAsync(tenantId, operation, cancellationToken)) .Evaluate(featureCode, operation); } public async Task> 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> FilterPermissionCodesAsync( Guid tenantId, IEnumerable 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(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> 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(); 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 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 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> 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); }