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 rows = await dbContext.Database.SqlQuery($""" 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(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 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); private sealed record QuotaSummaryProjection( string MetricCode, long Used, long Limit, DateTimeOffset PeriodStart, DateTimeOffset PeriodEnd); }