| | | 1 | | using Elsa.Authorization; |
| | | 2 | | using Elsa.Identity.Contracts; |
| | | 3 | | using Elsa.Permissions; |
| | | 4 | | using JetBrains.Annotations; |
| | | 5 | | using Microsoft.Extensions.DependencyInjection; |
| | | 6 | | using Microsoft.Extensions.Hosting; |
| | | 7 | | using Microsoft.Extensions.Logging; |
| | | 8 | | |
| | | 9 | | namespace Elsa.Identity.HostedServices; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Reports stored role permissions that no longer resolve, so an upgrade fails loudly rather than |
| | | 13 | | /// silently narrowing roles. |
| | | 14 | | /// </summary> |
| | | 15 | | /// <remarks> |
| | | 16 | | /// The authorization model deliberately breaks legacy permission strings rather than carrying a permanent |
| | | 17 | | /// alias layer, which would keep two vocabularies valid forever. This makes the consequence visible: every |
| | | 18 | | /// unresolvable permission is logged against the role that holds it. The whole-vocabulary grant survives |
| | | 19 | | /// unchanged, so an administrator cannot be locked out while the rest is re-authored. |
| | | 20 | | /// </remarks> |
| | | 21 | | [UsedImplicitly] |
| | 14 | 22 | | public class StoredPermissionValidator(IServiceScopeFactory scopeFactory, ILogger<StoredPermissionValidator> logger) : I |
| | | 23 | | { |
| | | 24 | | /// <inheritdoc /> |
| | | 25 | | public async Task StartAsync(CancellationToken cancellationToken) |
| | | 26 | | { |
| | 14 | 27 | | using var scope = scopeFactory.CreateScope(); |
| | 14 | 28 | | var roleProvider = scope.ServiceProvider.GetRequiredService<IRoleProvider>(); |
| | 14 | 29 | | var registry = scope.ServiceProvider.GetRequiredService<IPermissionDescriptorRegistry>(); |
| | | 30 | | |
| | | 31 | | IReadOnlyCollection<string> Unresolvable(IEnumerable<string> permissions) => |
| | 28 | 32 | | permissions.Where(x => !Resolves(registry, x)).ToArray(); |
| | | 33 | | |
| | | 34 | | try |
| | | 35 | | { |
| | 14 | 36 | | var roles = await roleProvider.FindManyAsync(new(), cancellationToken); |
| | 14 | 37 | | var affected = 0; |
| | | 38 | | |
| | 56 | 39 | | foreach (var role in roles) |
| | | 40 | | { |
| | 14 | 41 | | var unresolvable = Unresolvable(role.Permissions); |
| | | 42 | | |
| | 14 | 43 | | if (unresolvable.Count == 0) |
| | | 44 | | continue; |
| | | 45 | | |
| | 6 | 46 | | affected++; |
| | 6 | 47 | | logger.LogWarning( |
| | 6 | 48 | | "Role '{RoleName}' ({RoleId}) holds {Count} permission(s) that no longer resolve and will not author |
| | 6 | 49 | | role.Name, role.Id, unresolvable.Count, string.Join(", ", unresolvable)); |
| | | 50 | | } |
| | | 51 | | |
| | 14 | 52 | | if (affected > 0) |
| | 6 | 53 | | logger.LogWarning("{Count} role(s) hold unresolvable permissions. Re-author them against the permission |
| | 14 | 54 | | } |
| | 0 | 55 | | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) |
| | | 56 | | { |
| | 0 | 57 | | throw; |
| | | 58 | | } |
| | 0 | 59 | | catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) |
| | | 60 | | { |
| | | 61 | | // Reporting must never prevent the host from starting: an unreachable or half-migrated store |
| | | 62 | | // is exactly when an operator most needs the host up to fix it. |
| | 0 | 63 | | logger.LogWarning(ex, "Could not validate stored role permissions."); |
| | 0 | 64 | | } |
| | 14 | 65 | | } |
| | | 66 | | |
| | | 67 | | /// <inheritdoc /> |
| | 6 | 68 | | public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; |
| | | 69 | | |
| | | 70 | | private static bool Resolves(IPermissionDescriptorRegistry registry, string value) |
| | | 71 | | { |
| | 14 | 72 | | if (!Permission.TryParse(value, out var permission) || !permission.IsValidPattern) |
| | 2 | 73 | | return false; |
| | | 74 | | |
| | 12 | 75 | | if (permission.IsResourceWildcard) |
| | 4 | 76 | | return true; |
| | | 77 | | |
| | | 78 | | // A subtree grant reaching nothing is far more likely a typo ('workflow/*') than a grant for a |
| | | 79 | | // module yet to be installed, so it is reported rather than assumed forward-reaching. |
| | 8 | 80 | | if (permission.IsSubtree) |
| | | 81 | | { |
| | 5 | 82 | | var reached = registry.Reach(permission.Resource); |
| | | 83 | | |
| | | 84 | | // A concrete verb is only resolved when something under the subtree actually supports it: |
| | | 85 | | // 'workflows/*:frobnicate' reaches plenty and authorizes nothing, which is the same inert |
| | | 86 | | // grant an unreachable subtree is, and deserves the same warning. |
| | 5 | 87 | | return permission.IsVerbWildcard |
| | 5 | 88 | | ? reached.Count > 0 |
| | 8 | 89 | | : reached.Any(x => registry.Find(x)?.Supports(permission.Verb) == true); |
| | | 90 | | } |
| | | 91 | | |
| | 3 | 92 | | var descriptor = registry.Find(permission.Resource); |
| | | 93 | | |
| | 3 | 94 | | return descriptor is not null && (permission.IsVerbWildcard || descriptor.Supports(permission.Verb)); |
| | | 95 | | } |
| | | 96 | | } |