< Summary

Information
Class: Services.Authorization.Services.PermissionService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/Authorization/Services/PermissionService.cs
Line coverage
99%
Covered lines: 256
Uncovered lines: 1
Coverable lines: 257
Total lines: 381
Line coverage: 99.6%
Branch coverage
94%
Covered branches: 79
Total branches: 84
Branch coverage: 94%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetUserPermissionsAsync()100%88100%
GetUserPagePermissionsAsync()100%11100%
GetUserFieldPermissionsAsync()100%11100%
GetUserDataScopeAsync()100%44100%
HasPageAccessAsync()100%1010100%
HasFieldAccessAsync()100%88100%
InvalidateUserCache(...)100%22100%
InvalidateAll()100%22100%
LoadBundleWithRetryAsync()25%4492.85%
IsTransientDbError(...)100%1212100%
BuildBundleAsync()96.87%3232100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/Authorization/Services/PermissionService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Caching.Memory;
 3using Microsoft.Extensions.Logging;
 4using Microsoft.Extensions.Options;
 5using MySqlConnector;
 6using OzonDomains.Models.Authorization.Enums;
 7using OzonRepositories.Context;
 8using Services.Authorization.Dtos;
 9
 10namespace Services.Authorization.Services;
 11
 12/// <summary>
 13/// Реализация сервиса прав доступа. Агрегирует права из всех ролей пользователя,
 14/// применяет UserPageOverride / UserFieldOverride (override побеждает).
 15/// Стратегия объединения ролей: «максимальные права» (логическое ИЛИ).
 16/// </summary>
 17public sealed class PermissionService : IPermissionService
 18{
 19    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
 20    private readonly IMemoryCache _cache;
 21    private readonly ILogger<PermissionService> _logger;
 22    private readonly AppAuthorizationOptions _options;
 23
 124    private static readonly HashSet<string> AllUsersTrackedForInvalidation = new();
 125    private static readonly object _invalidationLock = new();
 26
 5027    public PermissionService(
 5028        IDbContextFactory<ApplicationDbContext> dbFactory,
 5029        IMemoryCache cache,
 5030        ILogger<PermissionService> logger,
 5031        IOptions<AppAuthorizationOptions> options)
 32    {
 5033        _dbFactory = dbFactory;
 5034        _cache = cache;
 5035        _logger = logger;
 5036        _options = options.Value;
 5037    }
 38
 39    public async Task<UserPermissionsBundle> GetUserPermissionsAsync(string userId, CancellationToken ct = default)
 40    {
 5841        if (string.IsNullOrWhiteSpace(userId))
 342            return new UserPermissionsBundle { UserId = userId ?? string.Empty };
 43
 5544        var key = AuthorizationConstants.UserCacheKeyPrefix + userId;
 5545        if (_cache.TryGetValue(key, out UserPermissionsBundle? cached) && cached is not null)
 746            return cached;
 47
 4848        var bundle = await LoadBundleWithRetryAsync(userId, ct);
 49
 4650        var entryOptions = new MemoryCacheEntryOptions
 4651        {
 4652            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CacheTtlMinutes)
 4653        };
 4654        _cache.Set(key, bundle, entryOptions);
 55
 9256        lock (_invalidationLock) AllUsersTrackedForInvalidation.Add(userId);
 4657        return bundle;
 5658    }
 59
 60    public async Task<List<PagePermissionDto>> GetUserPagePermissionsAsync(string userId, CancellationToken ct = default
 61    {
 162        var bundle = await GetUserPermissionsAsync(userId, ct);
 163        return bundle.Pages;
 164    }
 65
 66    public async Task<List<FieldPermissionDto>> GetUserFieldPermissionsAsync(string userId, string entityName, Cancellat
 67    {
 168        var bundle = await GetUserPermissionsAsync(userId, ct);
 169        return bundle.Fields
 270            .Where(f => string.Equals(f.EntityName, entityName, StringComparison.OrdinalIgnoreCase))
 171            .ToList();
 172    }
 73
 74    public async Task<string?> GetUserDataScopeAsync(string userId, string entityName, CancellationToken ct = default)
 75    {
 476        var bundle = await GetUserPermissionsAsync(userId, ct);
 577        if (bundle.IsSuperAdmin) return null;
 78
 379        var exprs = bundle.DataScopes
 580            .Where(s => string.Equals(s.EntityName, entityName, StringComparison.OrdinalIgnoreCase))
 381            .Select(s => $"({s.FilterExpression})")
 382            .ToList();
 83
 484        if (exprs.Count == 0) return null;
 285        return string.Join(" OR ", exprs);
 486    }
 87
 88    public async Task<bool> HasPageAccessAsync(string userId, string pageCode, AccessType accessType, CancellationToken 
 89    {
 1390        var bundle = await GetUserPermissionsAsync(userId, ct);
 1891        if (bundle.IsSuperAdmin) return true;
 92
 1593        var page = bundle.Pages.FirstOrDefault(p => string.Equals(p.PageCode, pageCode, StringComparison.OrdinalIgnoreCa
 994        if (page is null) return false;
 95
 796        return accessType switch
 797        {
 298            AccessType.View => page.CanView,
 199            AccessType.Create => page.CanCreate,
 1100            AccessType.Edit => page.CanEdit,
 1101            AccessType.Delete => page.CanDelete,
 1102            AccessType.Export => page.CanExport,
 1103            _ => false
 7104        };
 13105    }
 106
 107    public async Task<bool> HasFieldAccessAsync(string userId, string entityName, string fieldName, AccessType accessTyp
 108    {
 10109        var bundle = await GetUserPermissionsAsync(userId, ct);
 12110        if (bundle.IsSuperAdmin) return true;
 111
 8112        var field = bundle.Fields.FirstOrDefault(f =>
 14113            string.Equals(f.EntityName, entityName, StringComparison.OrdinalIgnoreCase) &&
 14114            string.Equals(f.FieldName, fieldName, StringComparison.OrdinalIgnoreCase));
 115
 116        // Если поле не зарегистрировано — разрешить по умолчанию (fail-open для незарегистрированных полей,
 117        // регистрация осуществляется через FieldDiscoveryService).
 10118        if (field is null) return true;
 119
 6120        return accessType switch
 6121        {
 2122            AccessType.Read => field.CanRead,
 1123            AccessType.Write => field.CanWrite,
 3124            _ => false
 6125        };
 10126    }
 127
 128    public void InvalidateUserCache(string userId)
 129    {
 7130        if (string.IsNullOrWhiteSpace(userId)) return;
 1131        _cache.Remove(AuthorizationConstants.UserCacheKeyPrefix + userId);
 2132        lock (_invalidationLock) AllUsersTrackedForInvalidation.Remove(userId);
 1133        _logger.LogInformation("Сброшен кэш прав пользователя {UserId}", userId);
 1134    }
 135
 136    public void InvalidateAll()
 137    {
 138        List<string> snapshot;
 1139        lock (_invalidationLock)
 140        {
 1141            snapshot = AllUsersTrackedForInvalidation.ToList();
 1142            AllUsersTrackedForInvalidation.Clear();
 1143        }
 30144        foreach (var uid in snapshot)
 14145            _cache.Remove(AuthorizationConstants.UserCacheKeyPrefix + uid);
 1146        _logger.LogInformation("Сброшен кэш прав для {Count} пользователей", snapshot.Count);
 1147    }
 148
 149    private async Task<UserPermissionsBundle> LoadBundleWithRetryAsync(string userId, CancellationToken ct)
 150    {
 151        const int maxAttempts = 3;
 48152        Exception? last = null;
 102153        for (var attempt = 1; attempt <= maxAttempts; attempt++)
 154        {
 155            try
 156            {
 51157                return await BuildBundleAsync(userId, ct);
 158            }
 5159            catch (Exception ex) when (IsTransientDbError(ex) && attempt < maxAttempts)
 160            {
 3161                last = ex;
 3162                _logger.LogWarning(
 3163                    ex,
 3164                    "Transient DB при загрузке прав {UserId}, попытка {Attempt}/{Max}",
 3165                    userId,
 3166                    attempt,
 3167                    maxAttempts);
 3168                await Task.Delay(TimeSpan.FromMilliseconds(300 * attempt), ct);
 169            }
 170        }
 171
 0172        throw last ?? new InvalidOperationException($"Не удалось загрузить права пользователя {userId}");
 46173    }
 174
 175    private static bool IsTransientDbError(Exception ex)
 176    {
 12177        for (var current = ex; current != null; current = current.InnerException)
 178        {
 5179            if (current is MySqlException or MySqlEndOfStreamException or TimeoutException or IOException)
 180            {
 4181                return true;
 182            }
 183        }
 184
 1185        return false;
 186    }
 187
 188    private async Task<UserPermissionsBundle> BuildBundleAsync(string userId, CancellationToken ct)
 189    {
 51190        await using var db = await _dbFactory.CreateDbContextAsync(ct);
 46191        var bundle = new UserPermissionsBundle { UserId = userId };
 192
 193        // Роли пользователя
 46194        var roleIds = await db.UserAppRoles.AsNoTracking()
 46195            .Where(ur => ur.UserId == userId)
 46196            .Select(ur => ur.AppRoleId)
 46197            .ToListAsync(ct);
 198
 46199        var roles = await db.AppRoles.AsNoTracking()
 46200            .Where(r => roleIds.Contains(r.Id) && r.IsActive)
 46201            .ToListAsync(ct);
 202
 82203        bundle.RoleNames = roles.Select(r => r.Name).ToList();
 82204        bundle.IsSuperAdmin = roles.Any(r => r.Name == AuthorizationConstants.SuperAdminRoleName);
 205
 46206        if (bundle.IsSuperAdmin)
 207        {
 208            // SuperAdmin получает полный доступ ко всем зарегистрированным страницам/полям
 10209            var allPages = await db.AppPages.AsNoTracking().Where(p => p.IsActive).ToListAsync(ct);
 13210            bundle.Pages = allPages.Select(p => new PagePermissionDto
 13211            {
 13212                PageId = p.Id,
 13213                PageCode = p.Code,
 13214                PageDisplayName = p.DisplayName,
 13215                CanView = true,
 13216                CanCreate = true,
 13217                CanEdit = true,
 13218                CanDelete = true,
 13219                CanExport = true
 13220            }).ToList();
 221
 10222            var allFields = await db.AppFields.AsNoTracking().Where(f => f.IsActive).ToListAsync(ct);
 12223            bundle.Fields = allFields.Select(f => new FieldPermissionDto
 12224            {
 12225                FieldId = f.Id,
 12226                EntityName = f.EntityName,
 12227                FieldName = f.FieldName,
 12228                FieldDisplayName = f.DisplayName,
 12229                FieldType = f.FieldType,
 12230                CanRead = true,
 12231                CanWrite = true
 12232            }).ToList();
 233
 10234            bundle.DataScopes = new List<DataScopeRef>();
 10235            return bundle;
 236        }
 237
 36238        if (roleIds.Count == 0)
 239        {
 11240            bundle.Pages = new();
 11241            bundle.Fields = new();
 242            // Персональные DataScope применяются независимо от наличия ролей.
 11243            var userScopesNoRole = await db.UserDataScopes.AsNoTracking()
 11244                .Include(u => u.DataScope)
 11245                .Where(u => u.UserId == userId && u.DataScope.IsActive)
 11246                .Select(u => u.DataScope)
 11247                .ToListAsync(ct);
 11248            bundle.DataScopes = userScopesNoRole
 1249                .GroupBy(s => s.Id)
 1250                .Select(g => new DataScopeRef
 1251                {
 1252                    Id = g.Key,
 1253                    Code = g.First().Code,
 1254                    EntityName = g.First().EntityName,
 1255                    FilterExpression = g.First().FilterExpression
 1256                })
 11257                .ToList();
 11258            return bundle;
 259        }
 260
 261        // Страницы: объединяем права всех ролей (OR), потом применяем override
 25262        var pagePerms = await db.RolePagePermissions.AsNoTracking()
 25263            .AsSplitQuery()
 25264            .Include(p => p.AppPage)
 25265            .Where(p => roleIds.Contains(p.AppRoleId) && p.AppPage.IsActive)
 25266            .ToListAsync(ct);
 267
 25268        var pageDict = pagePerms
 9269            .GroupBy(p => p.AppPageId)
 8270            .Select(g => new PagePermissionDto
 8271            {
 8272                PageId = g.Key,
 8273                PageCode = g.First().AppPage.Code,
 8274                PageDisplayName = g.First().AppPage.DisplayName,
 8275                CanView = g.Any(x => x.CanView),
 9276                CanCreate = g.Any(x => x.CanCreate),
 9277                CanEdit = g.Any(x => x.CanEdit),
 9278                CanDelete = g.Any(x => x.CanDelete),
 9279                CanExport = g.Any(x => x.CanExport)
 8280            })
 33281            .ToDictionary(p => p.PageId);
 282
 25283        var pageOverrides = await db.UserPageOverrides.AsNoTracking()
 25284            .Where(o => o.UserId == userId)
 25285            .ToListAsync(ct);
 286
 56287        foreach (var o in pageOverrides)
 288        {
 3289            if (!pageDict.TryGetValue(o.AppPageId, out var p))
 290            {
 2291                var page = await db.AppPages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.AppPageId, ct);
 2292                if (page is null) continue;
 1293                p = new PagePermissionDto
 1294                {
 1295                    PageId = page.Id, PageCode = page.Code, PageDisplayName = page.DisplayName
 1296                };
 1297                pageDict[o.AppPageId] = p;
 298            }
 3299            if (o.CanView.HasValue) p.CanView = o.CanView.Value;
 3300            if (o.CanCreate.HasValue) p.CanCreate = o.CanCreate.Value;
 3301            if (o.CanEdit.HasValue) p.CanEdit = o.CanEdit.Value;
 2302            if (o.CanDelete.HasValue) p.CanDelete = o.CanDelete.Value;
 3303            if (o.CanExport.HasValue) p.CanExport = o.CanExport.Value;
 2304        }
 25305        bundle.Pages = pageDict.Values.ToList();
 306
 307        // Поля: аналогично
 25308        var fieldPerms = await db.RoleFieldPermissions.AsNoTracking()
 25309            .AsSplitQuery()
 25310            .Include(p => p.AppField)
 25311            .Where(p => roleIds.Contains(p.AppRoleId) && p.AppField.IsActive)
 25312            .ToListAsync(ct);
 313
 25314        var fieldDict = fieldPerms
 11315            .GroupBy(p => p.AppFieldId)
 10316            .Select(g => new FieldPermissionDto
 10317            {
 10318                FieldId = g.Key,
 10319                EntityName = g.First().AppField.EntityName,
 10320                FieldName = g.First().AppField.FieldName,
 10321                FieldDisplayName = g.First().AppField.DisplayName,
 10322                FieldType = g.First().AppField.FieldType,
 10323                CanRead = g.Any(x => x.CanRead),
 11324                CanWrite = g.Any(x => x.CanWrite)
 10325            })
 35326            .ToDictionary(p => p.FieldId);
 327
 25328        var fieldOverrides = await db.UserFieldOverrides.AsNoTracking()
 25329            .Where(o => o.UserId == userId)
 25330            .ToListAsync(ct);
 331
 56332        foreach (var o in fieldOverrides)
 333        {
 3334            if (!fieldDict.TryGetValue(o.AppFieldId, out var f))
 335            {
 2336                var field = await db.AppFields.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.AppFieldId, ct);
 2337                if (field is null) continue;
 1338                f = new FieldPermissionDto
 1339                {
 1340                    FieldId = field.Id,
 1341                    EntityName = field.EntityName,
 1342                    FieldName = field.FieldName,
 1343                    FieldDisplayName = field.DisplayName,
 1344                    FieldType = field.FieldType
 1345                };
 1346                fieldDict[o.AppFieldId] = f;
 347            }
 3348            if (o.CanRead.HasValue) f.CanRead = o.CanRead.Value;
 4349            if (o.CanWrite.HasValue) f.CanWrite = o.CanWrite.Value;
 2350        }
 25351        bundle.Fields = fieldDict.Values.ToList();
 352
 353        // DataScopes (RLS): объединяем роль-scope и персональные привязки (OR на уровне списка scope'ов).
 25354        var roleScopes = await db.RoleDataScopes.AsNoTracking()
 25355            .AsSplitQuery()
 25356            .Include(r => r.DataScope)
 25357            .Where(r => roleIds.Contains(r.AppRoleId) && r.DataScope.IsActive)
 25358            .Select(r => r.DataScope)
 25359            .ToListAsync(ct);
 360
 25361        var userScopes = await db.UserDataScopes.AsNoTracking()
 25362            .AsSplitQuery()
 25363            .Include(u => u.DataScope)
 25364            .Where(u => u.UserId == userId && u.DataScope.IsActive)
 25365            .Select(u => u.DataScope)
 25366            .ToListAsync(ct);
 367
 25368        bundle.DataScopes = roleScopes.Concat(userScopes)
 11369            .GroupBy(s => s.Id)
 9370            .Select(g => new DataScopeRef
 9371            {
 9372                Id = g.Key,
 9373                Code = g.First().Code,
 9374                EntityName = g.First().EntityName,
 9375                FilterExpression = g.First().FilterExpression
 9376            })
 25377            .ToList();
 378
 25379        return bundle;
 46380    }
 381}