| | | 1 | | using Elsa.Authorization; |
| | | 2 | | using Elsa.UserTasks.Contracts; |
| | | 3 | | using Elsa.UserTasks.Models; |
| | | 4 | | using Elsa.UserTasks.Permissions; |
| | | 5 | | |
| | | 6 | | namespace Elsa.UserTasks.Services; |
| | | 7 | | |
| | | 8 | | public sealed class DefaultUserTaskAccessPolicy : IUserTaskAccessPolicy |
| | | 9 | | { |
| | | 10 | | /// <summary> |
| | | 11 | | /// Guests authenticate through a task-scoped invitation session. They may read and complete exactly one |
| | | 12 | | /// task and can never claim, release, reassign, schedule, invite, cancel, or manage. |
| | | 13 | | /// </summary> |
| | | 14 | | private static readonly UserTaskAccessOperation[] GuestOperations = |
| | | 15 | | [ |
| | | 16 | | UserTaskAccessOperation.ReadSummary, |
| | | 17 | | UserTaskAccessOperation.ReadProtected, |
| | | 18 | | UserTaskAccessOperation.Complete |
| | | 19 | | ]; |
| | | 20 | | |
| | | 21 | | public Task<UserTaskQueryScope?> CreateScopeAsync(UserTaskActor actor, UserTaskQueryScopeKind kind, CancellationToke |
| | | 22 | | { |
| | | 23 | | // A guest session is issued for one task and carries no list capability at all. |
| | | 24 | | if (actor.IsGuest || !actor.HasPermission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View)) |
| | | 25 | | return Task.FromResult<UserTaskQueryScope?>(null); |
| | | 26 | | |
| | | 27 | | var isManager = IsManager(actor); |
| | | 28 | | if (!isManager && kind is UserTaskQueryScopeKind.All or UserTaskQueryScopeKind.NeedsAttention) |
| | | 29 | | return Task.FromResult<UserTaskQueryScope?>(null); |
| | | 30 | | |
| | | 31 | | return Task.FromResult<UserTaskQueryScope?>(new UserTaskQueryScope( |
| | | 32 | | actor.Subject.TenantId, |
| | | 33 | | actor.Subject, |
| | | 34 | | actor.Groups, |
| | | 35 | | isManager, |
| | | 36 | | kind, |
| | | 37 | | ExcludeBlocking: !isManager)); |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | public Task<bool> AuthorizeAsync(UserTask task, UserTaskActor actor, UserTaskAccessOperation operation, Cancellation |
| | | 41 | | { |
| | | 42 | | if (!string.Equals(task.TenantId, actor.Subject.TenantId, StringComparison.Ordinal)) |
| | | 43 | | return Task.FromResult(false); |
| | | 44 | | if (!actor.HasPermission(RequiredPermission(operation))) |
| | | 45 | | return Task.FromResult(false); |
| | | 46 | | |
| | | 47 | | if (actor.IsGuest) |
| | | 48 | | return Task.FromResult(AuthorizeGuest(task, actor, operation)); |
| | | 49 | | |
| | | 50 | | var isManager = IsManager(actor); |
| | | 51 | | if (task.HealthSeverity == UserTaskHealthSeverity.Blocking && !isManager) |
| | | 52 | | return Task.FromResult(false); |
| | | 53 | | |
| | | 54 | | var isAssignee = task.Assignee?.Matches(actor.Subject) == true; |
| | | 55 | | var isCandidate = IsCandidate(task, actor); |
| | | 56 | | var isCompleter = task.CompletedBy?.Matches(actor.Subject) == true; |
| | | 57 | | var acted = task.Events.Any(x => x.Actor?.Matches(actor.Subject) == true); |
| | | 58 | | |
| | | 59 | | var result = operation switch |
| | | 60 | | { |
| | | 61 | | UserTaskAccessOperation.ReadSummary => isManager || isAssignee || isCandidate || isCompleter || acted, |
| | | 62 | | UserTaskAccessOperation.ReadProtected => isManager || (task.IsTerminal ? isCompleter : isAssignee), |
| | | 63 | | UserTaskAccessOperation.Claim => !task.IsTerminal && task.Status is (UserTaskStatus.Available or UserTaskSta |
| | | 64 | | UserTaskAccessOperation.Release => !task.IsTerminal && isAssignee, |
| | | 65 | | UserTaskAccessOperation.Assign => isManager, |
| | | 66 | | UserTaskAccessOperation.UpdateScheduling => isManager, |
| | | 67 | | // The completer may replay the same terminal operation for idempotency; new terminal |
| | | 68 | | // commands are rejected by the manager after operation lookup. |
| | | 69 | | UserTaskAccessOperation.Complete => task.IsTerminal ? isCompleter : isAssignee, |
| | | 70 | | UserTaskAccessOperation.Cancel => isManager, |
| | | 71 | | UserTaskAccessOperation.Manage => isManager, |
| | | 72 | | UserTaskAccessOperation.IssueInvitation => isManager, |
| | | 73 | | UserTaskAccessOperation.RetryResolution => isManager, |
| | | 74 | | _ => false |
| | | 75 | | }; |
| | | 76 | | |
| | | 77 | | return Task.FromResult(result); |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | private static bool AuthorizeGuest(UserTask task, UserTaskActor actor, UserTaskAccessOperation operation) |
| | | 81 | | { |
| | | 82 | | if (!string.Equals(actor.GuestTaskId, task.Id, StringComparison.Ordinal)) |
| | | 83 | | return false; |
| | | 84 | | if (!GuestOperations.Contains(operation)) |
| | | 85 | | return false; |
| | | 86 | | // The invitation claimed the task for the guest participant; losing that assignment (through a |
| | | 87 | | // manager reassignment or reissue) revokes the guest's access on the next request. |
| | | 88 | | if (task.Assignee?.Matches(actor.Subject) != true) |
| | | 89 | | return false; |
| | | 90 | | return operation != UserTaskAccessOperation.Complete || !task.IsTerminal; |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | /// <summary> |
| | | 94 | | /// A tenant-scoped manager must hold <c>user-tasks:supervise</c> (or a grant covering it). The actor flag |
| | | 95 | | /// on its own is host-supplied metadata and is never sufficient. |
| | | 96 | | /// </summary> |
| | | 97 | | private static bool IsManager(UserTaskActor actor) => |
| | | 98 | | !actor.IsGuest && actor.IsManager && actor.HasPermission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.S |
| | | 99 | | |
| | | 100 | | private static bool IsCandidate(UserTask task, UserTaskActor actor) |
| | | 101 | | { |
| | | 102 | | if (task.ExcludedUsers.Any(x => x.Matches(actor.Subject))) |
| | | 103 | | return false; |
| | | 104 | | |
| | | 105 | | if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot) |
| | | 106 | | return task.SnapshotMembers.Any(x => x.Matches(actor.Subject)); |
| | | 107 | | |
| | | 108 | | return task.CandidateUsers.Any(x => x.Matches(actor.Subject)) |
| | | 109 | | || task.CandidateGroups.Any(candidate => actor.Groups.Any(candidate.Matches)); |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | private static bool IsExcluded(UserTask task, UserTaskActor actor) => |
| | | 113 | | task.ExcludedUsers.Any(x => x.Matches(actor.Subject)) && !(IsManager(actor) && task.AllowManagerExclusionOverrid |
| | | 114 | | |
| | | 115 | | /// <summary> |
| | | 116 | | /// The permission an operation needs, which is the same one the endpoint offering it declares. Every |
| | | 117 | | /// operation acts on the task itself, so only the verb varies. |
| | | 118 | | /// </summary> |
| | | 119 | | private static Permission RequiredPermission(UserTaskAccessOperation operation) => |
| | | 120 | | new(UserTasksResourcePermissions.UserTasks, RequiredVerb(operation)); |
| | | 121 | | |
| | | 122 | | private static string RequiredVerb(UserTaskAccessOperation operation) => operation switch |
| | | 123 | | { |
| | | 124 | | UserTaskAccessOperation.ReadSummary or UserTaskAccessOperation.ReadProtected => CoreVerbs.View, |
| | | 125 | | UserTaskAccessOperation.Claim or UserTaskAccessOperation.Release => UserTaskVerbs.Claim, |
| | | 126 | | UserTaskAccessOperation.Complete => UserTaskVerbs.Complete, |
| | | 127 | | UserTaskAccessOperation.Assign => UserTaskVerbs.Assign, |
| | | 128 | | UserTaskAccessOperation.UpdateScheduling => CoreVerbs.Update, |
| | | 129 | | UserTaskAccessOperation.Cancel => UserTaskVerbs.Cancel, |
| | | 130 | | UserTaskAccessOperation.Manage or UserTaskAccessOperation.RetryResolution => UserTaskVerbs.Supervise, |
| | | 131 | | UserTaskAccessOperation.IssueInvitation => UserTaskVerbs.Invite, |
| | | 132 | | _ => CoreVerbs.View |
| | | 133 | | }; |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | /// <summary> |
| | | 137 | | /// A safe default for hosts that do not expose a participant directory. It keeps lookup optional and |
| | | 138 | | /// avoids coupling User Tasks to Elsa.Identity. |
| | | 139 | | /// </summary> |
| | | 140 | | public sealed class EmptyUserTaskParticipantDirectory : IUserTaskParticipantDirectory |
| | | 141 | | { |
| | | 142 | | public Task<ParticipantSearchResult> SearchAsync(UserTaskParticipantQuery query, CancellationToken cancellationToken |
| | 0 | 143 | | Task.FromResult(new ParticipantSearchResult([], null, 0)); |
| | | 144 | | |
| | | 145 | | public Task<IReadOnlyCollection<ParticipantReference>> ResolveDisplayNamesAsync(IReadOnlyCollection<ParticipantRefer |
| | 0 | 146 | | Task.FromResult(participants); |
| | | 147 | | |
| | | 148 | | public Task<IReadOnlyCollection<ParticipantReference>> EnumerateGroupMembersAsync(ParticipantReference group, Cancel |
| | 0 | 149 | | Task.FromResult<IReadOnlyCollection<ParticipantReference>>([]); |
| | | 150 | | } |