| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Caching.Memory; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | using Microsoft.Extensions.Options; |
| | | 5 | | using MySqlConnector; |
| | | 6 | | using OzonDomains.Models.Authorization.Enums; |
| | | 7 | | using OzonRepositories.Context; |
| | | 8 | | using Services.Authorization.Dtos; |
| | | 9 | | |
| | | 10 | | namespace Services.Authorization.Services; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Реализация сервиса прав доступа. Агрегирует права из всех ролей пользователя, |
| | | 14 | | /// применяет UserPageOverride / UserFieldOverride (override побеждает). |
| | | 15 | | /// Стратегия объединения ролей: «максимальные права» (логическое ИЛИ). |
| | | 16 | | /// </summary> |
| | | 17 | | public 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 | | |
| | 1 | 24 | | private static readonly HashSet<string> AllUsersTrackedForInvalidation = new(); |
| | 1 | 25 | | private static readonly object _invalidationLock = new(); |
| | | 26 | | |
| | 50 | 27 | | public PermissionService( |
| | 50 | 28 | | IDbContextFactory<ApplicationDbContext> dbFactory, |
| | 50 | 29 | | IMemoryCache cache, |
| | 50 | 30 | | ILogger<PermissionService> logger, |
| | 50 | 31 | | IOptions<AppAuthorizationOptions> options) |
| | | 32 | | { |
| | 50 | 33 | | _dbFactory = dbFactory; |
| | 50 | 34 | | _cache = cache; |
| | 50 | 35 | | _logger = logger; |
| | 50 | 36 | | _options = options.Value; |
| | 50 | 37 | | } |
| | | 38 | | |
| | | 39 | | public async Task<UserPermissionsBundle> GetUserPermissionsAsync(string userId, CancellationToken ct = default) |
| | | 40 | | { |
| | 58 | 41 | | if (string.IsNullOrWhiteSpace(userId)) |
| | 3 | 42 | | return new UserPermissionsBundle { UserId = userId ?? string.Empty }; |
| | | 43 | | |
| | 55 | 44 | | var key = AuthorizationConstants.UserCacheKeyPrefix + userId; |
| | 55 | 45 | | if (_cache.TryGetValue(key, out UserPermissionsBundle? cached) && cached is not null) |
| | 7 | 46 | | return cached; |
| | | 47 | | |
| | 48 | 48 | | var bundle = await LoadBundleWithRetryAsync(userId, ct); |
| | | 49 | | |
| | 46 | 50 | | var entryOptions = new MemoryCacheEntryOptions |
| | 46 | 51 | | { |
| | 46 | 52 | | AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CacheTtlMinutes) |
| | 46 | 53 | | }; |
| | 46 | 54 | | _cache.Set(key, bundle, entryOptions); |
| | | 55 | | |
| | 92 | 56 | | lock (_invalidationLock) AllUsersTrackedForInvalidation.Add(userId); |
| | 46 | 57 | | return bundle; |
| | 56 | 58 | | } |
| | | 59 | | |
| | | 60 | | public async Task<List<PagePermissionDto>> GetUserPagePermissionsAsync(string userId, CancellationToken ct = default |
| | | 61 | | { |
| | 1 | 62 | | var bundle = await GetUserPermissionsAsync(userId, ct); |
| | 1 | 63 | | return bundle.Pages; |
| | 1 | 64 | | } |
| | | 65 | | |
| | | 66 | | public async Task<List<FieldPermissionDto>> GetUserFieldPermissionsAsync(string userId, string entityName, Cancellat |
| | | 67 | | { |
| | 1 | 68 | | var bundle = await GetUserPermissionsAsync(userId, ct); |
| | 1 | 69 | | return bundle.Fields |
| | 2 | 70 | | .Where(f => string.Equals(f.EntityName, entityName, StringComparison.OrdinalIgnoreCase)) |
| | 1 | 71 | | .ToList(); |
| | 1 | 72 | | } |
| | | 73 | | |
| | | 74 | | public async Task<string?> GetUserDataScopeAsync(string userId, string entityName, CancellationToken ct = default) |
| | | 75 | | { |
| | 4 | 76 | | var bundle = await GetUserPermissionsAsync(userId, ct); |
| | 5 | 77 | | if (bundle.IsSuperAdmin) return null; |
| | | 78 | | |
| | 3 | 79 | | var exprs = bundle.DataScopes |
| | 5 | 80 | | .Where(s => string.Equals(s.EntityName, entityName, StringComparison.OrdinalIgnoreCase)) |
| | 3 | 81 | | .Select(s => $"({s.FilterExpression})") |
| | 3 | 82 | | .ToList(); |
| | | 83 | | |
| | 4 | 84 | | if (exprs.Count == 0) return null; |
| | 2 | 85 | | return string.Join(" OR ", exprs); |
| | 4 | 86 | | } |
| | | 87 | | |
| | | 88 | | public async Task<bool> HasPageAccessAsync(string userId, string pageCode, AccessType accessType, CancellationToken |
| | | 89 | | { |
| | 13 | 90 | | var bundle = await GetUserPermissionsAsync(userId, ct); |
| | 18 | 91 | | if (bundle.IsSuperAdmin) return true; |
| | | 92 | | |
| | 15 | 93 | | var page = bundle.Pages.FirstOrDefault(p => string.Equals(p.PageCode, pageCode, StringComparison.OrdinalIgnoreCa |
| | 9 | 94 | | if (page is null) return false; |
| | | 95 | | |
| | 7 | 96 | | return accessType switch |
| | 7 | 97 | | { |
| | 2 | 98 | | AccessType.View => page.CanView, |
| | 1 | 99 | | AccessType.Create => page.CanCreate, |
| | 1 | 100 | | AccessType.Edit => page.CanEdit, |
| | 1 | 101 | | AccessType.Delete => page.CanDelete, |
| | 1 | 102 | | AccessType.Export => page.CanExport, |
| | 1 | 103 | | _ => false |
| | 7 | 104 | | }; |
| | 13 | 105 | | } |
| | | 106 | | |
| | | 107 | | public async Task<bool> HasFieldAccessAsync(string userId, string entityName, string fieldName, AccessType accessTyp |
| | | 108 | | { |
| | 10 | 109 | | var bundle = await GetUserPermissionsAsync(userId, ct); |
| | 12 | 110 | | if (bundle.IsSuperAdmin) return true; |
| | | 111 | | |
| | 8 | 112 | | var field = bundle.Fields.FirstOrDefault(f => |
| | 14 | 113 | | string.Equals(f.EntityName, entityName, StringComparison.OrdinalIgnoreCase) && |
| | 14 | 114 | | string.Equals(f.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)); |
| | | 115 | | |
| | | 116 | | // Если поле не зарегистрировано — разрешить по умолчанию (fail-open для незарегистрированных полей, |
| | | 117 | | // регистрация осуществляется через FieldDiscoveryService). |
| | 10 | 118 | | if (field is null) return true; |
| | | 119 | | |
| | 6 | 120 | | return accessType switch |
| | 6 | 121 | | { |
| | 2 | 122 | | AccessType.Read => field.CanRead, |
| | 1 | 123 | | AccessType.Write => field.CanWrite, |
| | 3 | 124 | | _ => false |
| | 6 | 125 | | }; |
| | 10 | 126 | | } |
| | | 127 | | |
| | | 128 | | public void InvalidateUserCache(string userId) |
| | | 129 | | { |
| | 7 | 130 | | if (string.IsNullOrWhiteSpace(userId)) return; |
| | 1 | 131 | | _cache.Remove(AuthorizationConstants.UserCacheKeyPrefix + userId); |
| | 2 | 132 | | lock (_invalidationLock) AllUsersTrackedForInvalidation.Remove(userId); |
| | 1 | 133 | | _logger.LogInformation("Сброшен кэш прав пользователя {UserId}", userId); |
| | 1 | 134 | | } |
| | | 135 | | |
| | | 136 | | public void InvalidateAll() |
| | | 137 | | { |
| | | 138 | | List<string> snapshot; |
| | 1 | 139 | | lock (_invalidationLock) |
| | | 140 | | { |
| | 1 | 141 | | snapshot = AllUsersTrackedForInvalidation.ToList(); |
| | 1 | 142 | | AllUsersTrackedForInvalidation.Clear(); |
| | 1 | 143 | | } |
| | 30 | 144 | | foreach (var uid in snapshot) |
| | 14 | 145 | | _cache.Remove(AuthorizationConstants.UserCacheKeyPrefix + uid); |
| | 1 | 146 | | _logger.LogInformation("Сброшен кэш прав для {Count} пользователей", snapshot.Count); |
| | 1 | 147 | | } |
| | | 148 | | |
| | | 149 | | private async Task<UserPermissionsBundle> LoadBundleWithRetryAsync(string userId, CancellationToken ct) |
| | | 150 | | { |
| | | 151 | | const int maxAttempts = 3; |
| | 48 | 152 | | Exception? last = null; |
| | 102 | 153 | | for (var attempt = 1; attempt <= maxAttempts; attempt++) |
| | | 154 | | { |
| | | 155 | | try |
| | | 156 | | { |
| | 51 | 157 | | return await BuildBundleAsync(userId, ct); |
| | | 158 | | } |
| | 5 | 159 | | catch (Exception ex) when (IsTransientDbError(ex) && attempt < maxAttempts) |
| | | 160 | | { |
| | 3 | 161 | | last = ex; |
| | 3 | 162 | | _logger.LogWarning( |
| | 3 | 163 | | ex, |
| | 3 | 164 | | "Transient DB при загрузке прав {UserId}, попытка {Attempt}/{Max}", |
| | 3 | 165 | | userId, |
| | 3 | 166 | | attempt, |
| | 3 | 167 | | maxAttempts); |
| | 3 | 168 | | await Task.Delay(TimeSpan.FromMilliseconds(300 * attempt), ct); |
| | | 169 | | } |
| | | 170 | | } |
| | | 171 | | |
| | 0 | 172 | | throw last ?? new InvalidOperationException($"Не удалось загрузить права пользователя {userId}"); |
| | 46 | 173 | | } |
| | | 174 | | |
| | | 175 | | private static bool IsTransientDbError(Exception ex) |
| | | 176 | | { |
| | 12 | 177 | | for (var current = ex; current != null; current = current.InnerException) |
| | | 178 | | { |
| | 5 | 179 | | if (current is MySqlException or MySqlEndOfStreamException or TimeoutException or IOException) |
| | | 180 | | { |
| | 4 | 181 | | return true; |
| | | 182 | | } |
| | | 183 | | } |
| | | 184 | | |
| | 1 | 185 | | return false; |
| | | 186 | | } |
| | | 187 | | |
| | | 188 | | private async Task<UserPermissionsBundle> BuildBundleAsync(string userId, CancellationToken ct) |
| | | 189 | | { |
| | 51 | 190 | | await using var db = await _dbFactory.CreateDbContextAsync(ct); |
| | 46 | 191 | | var bundle = new UserPermissionsBundle { UserId = userId }; |
| | | 192 | | |
| | | 193 | | // Роли пользователя |
| | 46 | 194 | | var roleIds = await db.UserAppRoles.AsNoTracking() |
| | 46 | 195 | | .Where(ur => ur.UserId == userId) |
| | 46 | 196 | | .Select(ur => ur.AppRoleId) |
| | 46 | 197 | | .ToListAsync(ct); |
| | | 198 | | |
| | 46 | 199 | | var roles = await db.AppRoles.AsNoTracking() |
| | 46 | 200 | | .Where(r => roleIds.Contains(r.Id) && r.IsActive) |
| | 46 | 201 | | .ToListAsync(ct); |
| | | 202 | | |
| | 82 | 203 | | bundle.RoleNames = roles.Select(r => r.Name).ToList(); |
| | 82 | 204 | | bundle.IsSuperAdmin = roles.Any(r => r.Name == AuthorizationConstants.SuperAdminRoleName); |
| | | 205 | | |
| | 46 | 206 | | if (bundle.IsSuperAdmin) |
| | | 207 | | { |
| | | 208 | | // SuperAdmin получает полный доступ ко всем зарегистрированным страницам/полям |
| | 10 | 209 | | var allPages = await db.AppPages.AsNoTracking().Where(p => p.IsActive).ToListAsync(ct); |
| | 13 | 210 | | bundle.Pages = allPages.Select(p => new PagePermissionDto |
| | 13 | 211 | | { |
| | 13 | 212 | | PageId = p.Id, |
| | 13 | 213 | | PageCode = p.Code, |
| | 13 | 214 | | PageDisplayName = p.DisplayName, |
| | 13 | 215 | | CanView = true, |
| | 13 | 216 | | CanCreate = true, |
| | 13 | 217 | | CanEdit = true, |
| | 13 | 218 | | CanDelete = true, |
| | 13 | 219 | | CanExport = true |
| | 13 | 220 | | }).ToList(); |
| | | 221 | | |
| | 10 | 222 | | var allFields = await db.AppFields.AsNoTracking().Where(f => f.IsActive).ToListAsync(ct); |
| | 12 | 223 | | bundle.Fields = allFields.Select(f => new FieldPermissionDto |
| | 12 | 224 | | { |
| | 12 | 225 | | FieldId = f.Id, |
| | 12 | 226 | | EntityName = f.EntityName, |
| | 12 | 227 | | FieldName = f.FieldName, |
| | 12 | 228 | | FieldDisplayName = f.DisplayName, |
| | 12 | 229 | | FieldType = f.FieldType, |
| | 12 | 230 | | CanRead = true, |
| | 12 | 231 | | CanWrite = true |
| | 12 | 232 | | }).ToList(); |
| | | 233 | | |
| | 10 | 234 | | bundle.DataScopes = new List<DataScopeRef>(); |
| | 10 | 235 | | return bundle; |
| | | 236 | | } |
| | | 237 | | |
| | 36 | 238 | | if (roleIds.Count == 0) |
| | | 239 | | { |
| | 11 | 240 | | bundle.Pages = new(); |
| | 11 | 241 | | bundle.Fields = new(); |
| | | 242 | | // Персональные DataScope применяются независимо от наличия ролей. |
| | 11 | 243 | | var userScopesNoRole = await db.UserDataScopes.AsNoTracking() |
| | 11 | 244 | | .Include(u => u.DataScope) |
| | 11 | 245 | | .Where(u => u.UserId == userId && u.DataScope.IsActive) |
| | 11 | 246 | | .Select(u => u.DataScope) |
| | 11 | 247 | | .ToListAsync(ct); |
| | 11 | 248 | | bundle.DataScopes = userScopesNoRole |
| | 1 | 249 | | .GroupBy(s => s.Id) |
| | 1 | 250 | | .Select(g => new DataScopeRef |
| | 1 | 251 | | { |
| | 1 | 252 | | Id = g.Key, |
| | 1 | 253 | | Code = g.First().Code, |
| | 1 | 254 | | EntityName = g.First().EntityName, |
| | 1 | 255 | | FilterExpression = g.First().FilterExpression |
| | 1 | 256 | | }) |
| | 11 | 257 | | .ToList(); |
| | 11 | 258 | | return bundle; |
| | | 259 | | } |
| | | 260 | | |
| | | 261 | | // Страницы: объединяем права всех ролей (OR), потом применяем override |
| | 25 | 262 | | var pagePerms = await db.RolePagePermissions.AsNoTracking() |
| | 25 | 263 | | .AsSplitQuery() |
| | 25 | 264 | | .Include(p => p.AppPage) |
| | 25 | 265 | | .Where(p => roleIds.Contains(p.AppRoleId) && p.AppPage.IsActive) |
| | 25 | 266 | | .ToListAsync(ct); |
| | | 267 | | |
| | 25 | 268 | | var pageDict = pagePerms |
| | 9 | 269 | | .GroupBy(p => p.AppPageId) |
| | 8 | 270 | | .Select(g => new PagePermissionDto |
| | 8 | 271 | | { |
| | 8 | 272 | | PageId = g.Key, |
| | 8 | 273 | | PageCode = g.First().AppPage.Code, |
| | 8 | 274 | | PageDisplayName = g.First().AppPage.DisplayName, |
| | 8 | 275 | | CanView = g.Any(x => x.CanView), |
| | 9 | 276 | | CanCreate = g.Any(x => x.CanCreate), |
| | 9 | 277 | | CanEdit = g.Any(x => x.CanEdit), |
| | 9 | 278 | | CanDelete = g.Any(x => x.CanDelete), |
| | 9 | 279 | | CanExport = g.Any(x => x.CanExport) |
| | 8 | 280 | | }) |
| | 33 | 281 | | .ToDictionary(p => p.PageId); |
| | | 282 | | |
| | 25 | 283 | | var pageOverrides = await db.UserPageOverrides.AsNoTracking() |
| | 25 | 284 | | .Where(o => o.UserId == userId) |
| | 25 | 285 | | .ToListAsync(ct); |
| | | 286 | | |
| | 56 | 287 | | foreach (var o in pageOverrides) |
| | | 288 | | { |
| | 3 | 289 | | if (!pageDict.TryGetValue(o.AppPageId, out var p)) |
| | | 290 | | { |
| | 2 | 291 | | var page = await db.AppPages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.AppPageId, ct); |
| | 2 | 292 | | if (page is null) continue; |
| | 1 | 293 | | p = new PagePermissionDto |
| | 1 | 294 | | { |
| | 1 | 295 | | PageId = page.Id, PageCode = page.Code, PageDisplayName = page.DisplayName |
| | 1 | 296 | | }; |
| | 1 | 297 | | pageDict[o.AppPageId] = p; |
| | | 298 | | } |
| | 3 | 299 | | if (o.CanView.HasValue) p.CanView = o.CanView.Value; |
| | 3 | 300 | | if (o.CanCreate.HasValue) p.CanCreate = o.CanCreate.Value; |
| | 3 | 301 | | if (o.CanEdit.HasValue) p.CanEdit = o.CanEdit.Value; |
| | 2 | 302 | | if (o.CanDelete.HasValue) p.CanDelete = o.CanDelete.Value; |
| | 3 | 303 | | if (o.CanExport.HasValue) p.CanExport = o.CanExport.Value; |
| | 2 | 304 | | } |
| | 25 | 305 | | bundle.Pages = pageDict.Values.ToList(); |
| | | 306 | | |
| | | 307 | | // Поля: аналогично |
| | 25 | 308 | | var fieldPerms = await db.RoleFieldPermissions.AsNoTracking() |
| | 25 | 309 | | .AsSplitQuery() |
| | 25 | 310 | | .Include(p => p.AppField) |
| | 25 | 311 | | .Where(p => roleIds.Contains(p.AppRoleId) && p.AppField.IsActive) |
| | 25 | 312 | | .ToListAsync(ct); |
| | | 313 | | |
| | 25 | 314 | | var fieldDict = fieldPerms |
| | 11 | 315 | | .GroupBy(p => p.AppFieldId) |
| | 10 | 316 | | .Select(g => new FieldPermissionDto |
| | 10 | 317 | | { |
| | 10 | 318 | | FieldId = g.Key, |
| | 10 | 319 | | EntityName = g.First().AppField.EntityName, |
| | 10 | 320 | | FieldName = g.First().AppField.FieldName, |
| | 10 | 321 | | FieldDisplayName = g.First().AppField.DisplayName, |
| | 10 | 322 | | FieldType = g.First().AppField.FieldType, |
| | 10 | 323 | | CanRead = g.Any(x => x.CanRead), |
| | 11 | 324 | | CanWrite = g.Any(x => x.CanWrite) |
| | 10 | 325 | | }) |
| | 35 | 326 | | .ToDictionary(p => p.FieldId); |
| | | 327 | | |
| | 25 | 328 | | var fieldOverrides = await db.UserFieldOverrides.AsNoTracking() |
| | 25 | 329 | | .Where(o => o.UserId == userId) |
| | 25 | 330 | | .ToListAsync(ct); |
| | | 331 | | |
| | 56 | 332 | | foreach (var o in fieldOverrides) |
| | | 333 | | { |
| | 3 | 334 | | if (!fieldDict.TryGetValue(o.AppFieldId, out var f)) |
| | | 335 | | { |
| | 2 | 336 | | var field = await db.AppFields.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.AppFieldId, ct); |
| | 2 | 337 | | if (field is null) continue; |
| | 1 | 338 | | f = new FieldPermissionDto |
| | 1 | 339 | | { |
| | 1 | 340 | | FieldId = field.Id, |
| | 1 | 341 | | EntityName = field.EntityName, |
| | 1 | 342 | | FieldName = field.FieldName, |
| | 1 | 343 | | FieldDisplayName = field.DisplayName, |
| | 1 | 344 | | FieldType = field.FieldType |
| | 1 | 345 | | }; |
| | 1 | 346 | | fieldDict[o.AppFieldId] = f; |
| | | 347 | | } |
| | 3 | 348 | | if (o.CanRead.HasValue) f.CanRead = o.CanRead.Value; |
| | 4 | 349 | | if (o.CanWrite.HasValue) f.CanWrite = o.CanWrite.Value; |
| | 2 | 350 | | } |
| | 25 | 351 | | bundle.Fields = fieldDict.Values.ToList(); |
| | | 352 | | |
| | | 353 | | // DataScopes (RLS): объединяем роль-scope и персональные привязки (OR на уровне списка scope'ов). |
| | 25 | 354 | | var roleScopes = await db.RoleDataScopes.AsNoTracking() |
| | 25 | 355 | | .AsSplitQuery() |
| | 25 | 356 | | .Include(r => r.DataScope) |
| | 25 | 357 | | .Where(r => roleIds.Contains(r.AppRoleId) && r.DataScope.IsActive) |
| | 25 | 358 | | .Select(r => r.DataScope) |
| | 25 | 359 | | .ToListAsync(ct); |
| | | 360 | | |
| | 25 | 361 | | var userScopes = await db.UserDataScopes.AsNoTracking() |
| | 25 | 362 | | .AsSplitQuery() |
| | 25 | 363 | | .Include(u => u.DataScope) |
| | 25 | 364 | | .Where(u => u.UserId == userId && u.DataScope.IsActive) |
| | 25 | 365 | | .Select(u => u.DataScope) |
| | 25 | 366 | | .ToListAsync(ct); |
| | | 367 | | |
| | 25 | 368 | | bundle.DataScopes = roleScopes.Concat(userScopes) |
| | 11 | 369 | | .GroupBy(s => s.Id) |
| | 9 | 370 | | .Select(g => new DataScopeRef |
| | 9 | 371 | | { |
| | 9 | 372 | | Id = g.Key, |
| | 9 | 373 | | Code = g.First().Code, |
| | 9 | 374 | | EntityName = g.First().EntityName, |
| | 9 | 375 | | FilterExpression = g.First().FilterExpression |
| | 9 | 376 | | }) |
| | 25 | 377 | | .ToList(); |
| | | 378 | | |
| | 25 | 379 | | return bundle; |
| | 46 | 380 | | } |
| | | 381 | | } |