< Summary

Information
Class: Elsa.ExternalAuthentication.Services.PermissionGrantBoundary
Assembly: Elsa.ExternalAuthentication
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/DefaultPermissionGrantResolver.cs
Line coverage
100%
Covered lines: 11
Uncovered lines: 0
Coverable lines: 11
Total lines: 149
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
Allows(...)100%1010100%
Parse(...)100%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.ExternalAuthentication/Services/DefaultPermissionGrantResolver.cs

#LineLine coverage
 1using System.Security.Claims;
 2using Elsa.Authorization;
 3using Elsa.ExternalAuthentication.Contracts;
 4using Elsa.ExternalAuthentication.Models;
 5using Elsa.ExternalAuthentication.Options;
 6using Elsa.ExternalAuthentication.Permissions;
 7using Elsa.Permissions;
 8using Microsoft.Extensions.Options;
 9
 10namespace Elsa.ExternalAuthentication.Services;
 11
 12public 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
 90public sealed class DefaultPermissionDelegationAuthorizer(IOptions<ExternalAuthenticationOptions> options, IPermissionEv
 91{
 92    public ValueTask<PermissionDelegationResult> AuthorizeAsync(ClaimsPrincipal actor, IReadOnlyCollection<GrantSourceSe
 93    {
 94        cancellationToken.ThrowIfCancellationRequested();
 95        var boundary = new PermissionGrantBoundary(options.Value.PermissionGrants);
 96        var configuredPermissions = selections.SelectMany(x => PermissionGrantMappingSettings.Read(x.Settings)).SelectMa
 97            .Concat(selections.Where(x => string.Equals(x.Type, ClaimPassThroughPermissionGrantSource.SourceType, String
 98            .Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray();
 99        var unrestricted = permissionEvaluator.HasPermission(actor, ExternalAuthenticationResourcePermissions.Permission
 100        var mayDelegate = unrestricted || permissionEvaluator.HasPermission(actor, ExternalAuthenticationResourcePermiss
 101        var unauthorized = configuredPermissions.Where(permission => !boundary.Allows(permission) || !mayDelegate || (!u
 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>
 111internal sealed class PermissionGrantBoundary
 112{
 113    private readonly IReadOnlyCollection<Permission> _allowed;
 114    private readonly IReadOnlyCollection<Permission> _denied;
 115    private readonly bool _isUnusable;
 116
 43117    public PermissionGrantBoundary(PermissionGrantOptions options)
 118    {
 43119        _allowed = Parse(options.AllowedPermissions);
 43120        _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.
 43128        _isUnusable = _allowed.Count != options.AllowedPermissions.Count || _denied.Count != options.DeniedPermissions.C
 43129    }
 130
 131    public bool Allows(string permission)
 132    {
 53133        if (_isUnusable || !Permission.TryParse(permission, out var candidate))
 3134            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.
 65139        if (_denied.Any(x => PermissionMatcher.Satisfies(x, candidate) || PermissionMatcher.Satisfies(candidate, x)))
 11140            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.
 44144        return _allowed.Count == 0 || _allowed.Any(x => PermissionMatcher.Satisfies(x, candidate));
 145    }
 146
 147    private static IReadOnlyCollection<Permission> Parse(IEnumerable<string> values) =>
 109148        values.Select(x => Permission.TryParse(x, out var permission) ? permission : (Permission?)null).OfType<Permissio
 149}