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 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> 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(StringComparer.Ordinal); foreach (var code in codes) { if ((await EvaluateAsync(tenantId, code, operation, cancellationToken)).Allowed) { enabled.Add(code); } } return enabled; } 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 allowed = new HashSet(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> 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); }