| | | 1 | | using System.Security.Claims; |
| | | 2 | | using Elsa.Authorization; |
| | | 3 | | using Elsa.ExternalAuthentication.Contracts; |
| | | 4 | | using Elsa.ExternalAuthentication.Models; |
| | | 5 | | using Elsa.ExternalAuthentication.Options; |
| | | 6 | | using Elsa.ExternalAuthentication.Permissions; |
| | | 7 | | using Elsa.Permissions; |
| | | 8 | | using Microsoft.Extensions.Options; |
| | | 9 | | |
| | | 10 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 11 | | |
| | | 12 | | public sealed class DefaultPermissionGrantResolver( |
| | | 13 | | IEnumerable<IPermissionGrantSource> sources, |
| | | 14 | | IPermissionDescriptorRegistry descriptors, |
| | | 15 | | IOptions<ExternalAuthenticationOptions> options) : IPermissionGrantResolver |
| | | 16 | | { |
| | | 17 | | private readonly IReadOnlyDictionary<string, IPermissionGrantSource> _sources = sources.ToDictionary(x => x.Type, St |
| | | 18 | | |
| | | 19 | | public async ValueTask<PermissionGrantResult> ResolveAsync(PermissionGrantResolutionContext context, CancellationTok |
| | | 20 | | { |
| | | 21 | | var grants = new List<PermissionGrant>(); |
| | | 22 | | var warnings = new List<PermissionGrantWarning>(); |
| | | 23 | | var warningKeys = new HashSet<(string Code, string Message)>(); |
| | | 24 | | var boundary = new PermissionGrantBoundary(options.Value.PermissionGrants); |
| | | 25 | | |
| | | 26 | | foreach (var selection in context.Connection.Connection.PermissionGrantSources.OrderBy(x => x.Order).ThenBy(x => |
| | | 27 | | { |
| | | 28 | | if (!IsAllowedSource(selection.Type) || !_sources.TryGetValue(selection.Type, out var source)) |
| | | 29 | | { |
| | | 30 | | AddWarning(warnings, warningKeys, new("permission_grant_source_unavailable", $"The permission grant sour |
| | | 31 | | continue; |
| | | 32 | | } |
| | | 33 | | |
| | | 34 | | var result = await source.GetGrantsAsync(new(context.TargetTenantId, context.UserId, context.Connection, con |
| | | 35 | | foreach (var warning in result.Warnings) |
| | | 36 | | AddWarning(warnings, warningKeys, warning); |
| | | 37 | | foreach (var grant in result.Grants) |
| | | 38 | | { |
| | | 39 | | // A value the evaluator cannot parse authorizes nothing, so carrying it into a token only |
| | | 40 | | // hides the mistake. Say so rather than passing it through as an opaque string. |
| | | 41 | | if (!Permission.TryParse(grant.Permission, out _)) |
| | | 42 | | { |
| | | 43 | | AddWarning(warnings, warningKeys, new("malformed_permission", $"The permission '{grant.Permission}' |
| | | 44 | | continue; |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | if (!boundary.Allows(grant.Permission)) |
| | | 48 | | { |
| | | 49 | | AddWarning(warnings, warningKeys, new("permission_denied_by_deployment", $"The permission '{grant.Pe |
| | | 50 | | continue; |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | // Checked against the core catalog, which is keyed by resource and lists the verbs each one |
| | | 54 | | // accepts. The module used to keep its own registry, fed only its legacy permission names, so |
| | | 55 | | // once the vocabulary changed every grant looked unknown and the warning became constant noise. |
| | | 56 | | if (!IsAdvertised(descriptors, grant.Permission)) |
| | | 57 | | AddWarning(warnings, warningKeys, new("unknown_permission_descriptor", $"No module advertises a desc |
| | | 58 | | |
| | | 59 | | if (grants.All(x => !string.Equals(x.Permission, grant.Permission, StringComparison.Ordinal))) |
| | | 60 | | grants.Add(grant); |
| | | 61 | | } |
| | | 62 | | } |
| | | 63 | | |
| | | 64 | | return new(grants, warnings); |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | /// <summary> |
| | | 68 | | /// Whether some module advertises <paramref name="permission"/>. A wildcard is advertised by definition: |
| | | 69 | | /// it names a pattern rather than one resource, so there is no single descriptor to look it up in. |
| | | 70 | | /// </summary> |
| | | 71 | | private static bool IsAdvertised(IPermissionDescriptorRegistry descriptors, string permission) |
| | | 72 | | { |
| | | 73 | | if (!Permission.TryParse(permission, out var parsed)) |
| | | 74 | | return false; |
| | | 75 | | |
| | | 76 | | if (parsed.HasWildcard) |
| | | 77 | | return true; |
| | | 78 | | |
| | | 79 | | return descriptors.Find(parsed.Resource)?.Supports(parsed.Verb) == true; |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | private bool IsAllowedSource(string type) => options.Value.AllowedPermissionGrantSourceTypes.Count == 0 || options.V |
| | | 83 | | private static void AddWarning(ICollection<PermissionGrantWarning> warnings, ISet<(string Code, string Message)> key |
| | | 84 | | { |
| | | 85 | | if (keys.Add((warning.Code, warning.Message))) |
| | | 86 | | warnings.Add(warning); |
| | | 87 | | } |
| | | 88 | | } |
| | | 89 | | |
| | 8 | 90 | | public sealed class DefaultPermissionDelegationAuthorizer(IOptions<ExternalAuthenticationOptions> options, IPermissionEv |
| | | 91 | | { |
| | | 92 | | public ValueTask<PermissionDelegationResult> AuthorizeAsync(ClaimsPrincipal actor, IReadOnlyCollection<GrantSourceSe |
| | | 93 | | { |
| | 12 | 94 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 12 | 95 | | var boundary = new PermissionGrantBoundary(options.Value.PermissionGrants); |
| | 34 | 96 | | var configuredPermissions = selections.SelectMany(x => PermissionGrantMappingSettings.Read(x.Settings)).SelectMa |
| | 14 | 97 | | .Concat(selections.Where(x => string.Equals(x.Type, ClaimPassThroughPermissionGrantSource.SourceType, String |
| | 12 | 98 | | .Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray(); |
| | 12 | 99 | | var unrestricted = permissionEvaluator.HasPermission(actor, ExternalAuthenticationResourcePermissions.Permission |
| | 12 | 100 | | var mayDelegate = unrestricted || permissionEvaluator.HasPermission(actor, ExternalAuthenticationResourcePermiss |
| | 24 | 101 | | var unauthorized = configuredPermissions.Where(permission => !boundary.Allows(permission) || !mayDelegate || (!u |
| | 12 | 102 | | return ValueTask.FromResult(new PermissionDelegationResult(unauthorized.Length == 0, unauthorized)); |
| | | 103 | | } |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | /// <summary> |
| | | 107 | | /// The deployment's allow and deny boundary on delegated permissions. Both lists are permission patterns, |
| | | 108 | | /// so they read the same way a role does: <c>workflows/*:delete</c> denies every delete beneath |
| | | 109 | | /// <c>workflows</c>, not just a permission spelled exactly that way. |
| | | 110 | | /// </summary> |
| | | 111 | | internal sealed class PermissionGrantBoundary |
| | | 112 | | { |
| | | 113 | | private readonly IReadOnlyCollection<Permission> _allowed; |
| | | 114 | | private readonly IReadOnlyCollection<Permission> _denied; |
| | | 115 | | private readonly bool _isUnusable; |
| | | 116 | | |
| | | 117 | | public PermissionGrantBoundary(PermissionGrantOptions options) |
| | | 118 | | { |
| | | 119 | | _allowed = Parse(options.AllowedPermissions); |
| | | 120 | | _denied = Parse(options.DeniedPermissions); |
| | | 121 | | |
| | | 122 | | // A boundary the deployment configured but that does not parse is a configuration error, and the only |
| | | 123 | | // safe reading of one is "allow nothing". Dropping the unparseable entries and carrying on would turn |
| | | 124 | | // a typo in the allow list into no allow list at all -- an empty allow list means unrestricted -- and |
| | | 125 | | // a typo in the deny list into a silent un-denying of whatever it named. ExternalAuthenticationOptionsValidator |
| | | 126 | | // rejects this at startup, so reaching it here means that validation was bypassed rather than that an |
| | | 127 | | // operator is mid-edit. |
| | | 128 | | _isUnusable = _allowed.Count != options.AllowedPermissions.Count || _denied.Count != options.DeniedPermissions.C |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | public bool Allows(string permission) |
| | | 132 | | { |
| | | 133 | | if (_isUnusable || !Permission.TryParse(permission, out var candidate)) |
| | | 134 | | return false; |
| | | 135 | | |
| | | 136 | | // Deny wins, and it is tested in both directions: a grant beneath a denied subtree is denied, and a |
| | | 137 | | // wildcard grant that would reach a denied permission is denied too. Testing only one direction |
| | | 138 | | // would let 'workflows/*:delete' hand out a delete the deployment denied by name. |
| | | 139 | | if (_denied.Any(x => PermissionMatcher.Satisfies(x, candidate) || PermissionMatcher.Satisfies(candidate, x))) |
| | | 140 | | return false; |
| | | 141 | | |
| | | 142 | | // Allow is one-directional on purpose. An allow entry must cover the whole grant, so a grant broader |
| | | 143 | | // than anything allowed is refused rather than admitted for the part that overlaps. |
| | | 144 | | return _allowed.Count == 0 || _allowed.Any(x => PermissionMatcher.Satisfies(x, candidate)); |
| | | 145 | | } |
| | | 146 | | |
| | | 147 | | private static IReadOnlyCollection<Permission> Parse(IEnumerable<string> values) => |
| | | 148 | | values.Select(x => Permission.TryParse(x, out var permission) ? permission : (Permission?)null).OfType<Permissio |
| | | 149 | | } |