| | | 1 | | using System.Text.Json; |
| | | 2 | | using Elsa.UserTasks.Contracts; |
| | | 3 | | using Elsa.UserTasks.Models; |
| | | 4 | | |
| | | 5 | | namespace Elsa.UserTasks.Services; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Projects the internal aggregate onto the wire contract. Every protected value passes through an explicit |
| | | 9 | | /// policy decision here, so an endpoint can never widen disclosure by choosing a different response type. |
| | | 10 | | /// </summary> |
| | | 11 | | public static class UserTaskModelMapper |
| | | 12 | | { |
| | 1 | 13 | | private static readonly UserTaskAccessOperation[] ActionOperations = |
| | 1 | 14 | | [ |
| | 1 | 15 | | UserTaskAccessOperation.Claim, |
| | 1 | 16 | | UserTaskAccessOperation.Release, |
| | 1 | 17 | | UserTaskAccessOperation.Assign, |
| | 1 | 18 | | UserTaskAccessOperation.UpdateScheduling, |
| | 1 | 19 | | UserTaskAccessOperation.Complete, |
| | 1 | 20 | | UserTaskAccessOperation.Cancel, |
| | 1 | 21 | | UserTaskAccessOperation.IssueInvitation, |
| | 1 | 22 | | UserTaskAccessOperation.RetryResolution |
| | 1 | 23 | | ]; |
| | | 24 | | |
| | 12 | 25 | | public static string ActionName(UserTaskAccessOperation operation) => operation switch |
| | 12 | 26 | | { |
| | 4 | 27 | | UserTaskAccessOperation.Claim => "claim", |
| | 3 | 28 | | UserTaskAccessOperation.Release => "release", |
| | 0 | 29 | | UserTaskAccessOperation.Assign => "assign", |
| | 0 | 30 | | UserTaskAccessOperation.UpdateScheduling => "update-scheduling", |
| | 5 | 31 | | UserTaskAccessOperation.Complete => "complete", |
| | 0 | 32 | | UserTaskAccessOperation.Cancel => "cancel", |
| | 0 | 33 | | UserTaskAccessOperation.IssueInvitation => "invite", |
| | 0 | 34 | | UserTaskAccessOperation.RetryResolution => "retry-resolution", |
| | 0 | 35 | | _ => operation.ToString().ToLowerInvariant() |
| | 12 | 36 | | }; |
| | | 37 | | |
| | | 38 | | public static async Task<UserTaskSummary> ToSummaryAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy p |
| | | 39 | | { |
| | 9 | 40 | | var allowed = new List<string>(); |
| | 162 | 41 | | foreach (var operation in ActionOperations) |
| | | 42 | | { |
| | 72 | 43 | | if (await policy.AuthorizeAsync(task, actor, operation, cancellationToken)) |
| | 12 | 44 | | allowed.Add(ActionName(operation)); |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | // Blocking health is an operator signal. Surfacing it to an ordinary participant would leak that a |
| | | 48 | | // directory or form provider failed, so it is folded away unless the caller manages the tenant. |
| | 9 | 49 | | var healthVisible = actor.IsManager && !actor.IsGuest; |
| | 9 | 50 | | var workflowVisible = !actor.IsGuest; |
| | 9 | 51 | | return new UserTaskSummary |
| | 9 | 52 | | { |
| | 9 | 53 | | Id = task.Id, |
| | 9 | 54 | | Title = task.Title, |
| | 9 | 55 | | Summary = task.Summary, |
| | 9 | 56 | | Reference = task.Reference, |
| | 9 | 57 | | Tags = task.Tags.ToArray(), |
| | 9 | 58 | | TaskType = task.TaskType, |
| | 9 | 59 | | Status = task.Status.ToString(), |
| | 9 | 60 | | Priority = task.Priority, |
| | 9 | 61 | | Assignee = actor.IsGuest ? null : UserTaskParticipantSummary.From(task.Assignee), |
| | 9 | 62 | | CandidateSummary = actor.IsGuest ? null : DescribeCandidates(task), |
| | 9 | 63 | | DueAt = task.DueAt, |
| | 9 | 64 | | IsOverdue = task.IsOverdue, |
| | 9 | 65 | | CreatedAt = task.CreatedAt, |
| | 9 | 66 | | UpdatedAt = task.UpdatedAt, |
| | 9 | 67 | | AssignedAt = actor.IsGuest ? null : task.AssignedAt, |
| | 9 | 68 | | CompletedAt = task.CompletedAt, |
| | 9 | 69 | | WorkflowDefinitionId = workflowVisible ? NullIfEmpty(task.WorkflowDefinitionId) : null, |
| | 9 | 70 | | WorkflowDefinitionName = workflowVisible ? task.WorkflowDefinitionName : null, |
| | 9 | 71 | | WorkflowDefinitionVersion = workflowVisible ? task.WorkflowDefinitionVersion : null, |
| | 9 | 72 | | WorkflowInstanceId = workflowVisible ? NullIfEmpty(task.WorkflowInstanceId) : null, |
| | 9 | 73 | | WorkflowInstanceReference = workflowVisible ? task.WorkflowInstanceReference : null, |
| | 9 | 74 | | HealthSeverity = healthVisible ? task.HealthSeverity?.ToString() : null, |
| | 9 | 75 | | HealthCode = healthVisible ? task.HealthCode : null, |
| | 9 | 76 | | AllowedActions = allowed, |
| | 9 | 77 | | Revision = task.Revision |
| | 9 | 78 | | }; |
| | 9 | 79 | | } |
| | | 80 | | |
| | | 81 | | public static async Task<UserTaskDetail> ToDetailAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy pol |
| | | 82 | | { |
| | 5 | 83 | | var summary = await ToSummaryAsync(task, actor, policy, cancellationToken); |
| | 5 | 84 | | var canReadProtected = await policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellat |
| | 5 | 85 | | var canViewHistory = !actor.IsGuest && (actor.IsManager || canReadProtected); |
| | 5 | 86 | | var disclosure = new UserTaskDisclosure |
| | 5 | 87 | | { |
| | 5 | 88 | | CanViewProtected = canReadProtected, |
| | 5 | 89 | | CanViewWorkflow = !actor.IsGuest, |
| | 5 | 90 | | CanViewHistory = canViewHistory, |
| | 5 | 91 | | GuestVisible = actor.IsGuest |
| | 5 | 92 | | }; |
| | | 93 | | |
| | | 94 | | // Guests may only complete the action keys their invitation was issued for, so the action list they |
| | | 95 | | // receive is intersected with that allowlist rather than showing the workflow's full action set. |
| | 5 | 96 | | var actions = task.Actions |
| | 10 | 97 | | .Where(action => !actor.IsGuest || actor.GuestAllowedActions.Contains(action.Key)) |
| | 8 | 98 | | .Select(action => new UserTaskFormAction(action.Key, action.Label)) |
| | 5 | 99 | | .ToArray(); |
| | | 100 | | |
| | 5 | 101 | | return new UserTaskDetail |
| | 5 | 102 | | { |
| | 5 | 103 | | Id = summary.Id, |
| | 5 | 104 | | Title = summary.Title, |
| | 5 | 105 | | Summary = summary.Summary, |
| | 5 | 106 | | Reference = summary.Reference, |
| | 5 | 107 | | Tags = summary.Tags, |
| | 5 | 108 | | TaskType = summary.TaskType, |
| | 5 | 109 | | Status = summary.Status, |
| | 5 | 110 | | Priority = summary.Priority, |
| | 5 | 111 | | Assignee = summary.Assignee, |
| | 5 | 112 | | CandidateSummary = summary.CandidateSummary, |
| | 5 | 113 | | DueAt = summary.DueAt, |
| | 5 | 114 | | IsOverdue = summary.IsOverdue, |
| | 5 | 115 | | CreatedAt = summary.CreatedAt, |
| | 5 | 116 | | UpdatedAt = summary.UpdatedAt, |
| | 5 | 117 | | AssignedAt = summary.AssignedAt, |
| | 5 | 118 | | CompletedAt = summary.CompletedAt, |
| | 5 | 119 | | WorkflowDefinitionId = summary.WorkflowDefinitionId, |
| | 5 | 120 | | WorkflowDefinitionName = summary.WorkflowDefinitionName, |
| | 5 | 121 | | WorkflowDefinitionVersion = summary.WorkflowDefinitionVersion, |
| | 5 | 122 | | WorkflowInstanceId = summary.WorkflowInstanceId, |
| | 5 | 123 | | WorkflowInstanceReference = summary.WorkflowInstanceReference, |
| | 5 | 124 | | HealthSeverity = summary.HealthSeverity, |
| | 5 | 125 | | HealthCode = summary.HealthCode, |
| | 5 | 126 | | AllowedActions = summary.AllowedActions, |
| | 5 | 127 | | Revision = summary.Revision, |
| | 5 | 128 | | Instructions = canReadProtected ? task.Instructions : null, |
| | 5 | 129 | | Data = canReadProtected ? task.TaskData : null, |
| | 5 | 130 | | Disclosure = disclosure, |
| | 5 | 131 | | Workflow = disclosure.CanViewWorkflow ? ToWorkflowContext(task) : null, |
| | 5 | 132 | | Form = ToFormProjection(task, actions, canReadProtected), |
| | 5 | 133 | | Actions = actions, |
| | 5 | 134 | | Outcome = canReadProtected ? task.CompletionActionKey : null, |
| | 5 | 135 | | Response = canReadProtected ? task.CompletionData : null, |
| | 5 | 136 | | CompletedBy = canReadProtected && !actor.IsGuest ? UserTaskParticipantSummary.From(task.CompletedBy) : null |
| | 5 | 137 | | }; |
| | 5 | 138 | | } |
| | | 139 | | |
| | | 140 | | public static async Task<UserTaskCapabilities> ToCapabilitiesAsync(UserTask task, UserTaskActor actor, IUserTaskAcce |
| | | 141 | | { |
| | 0 | 142 | | var summary = await ToSummaryAsync(task, actor, policy, cancellationToken); |
| | 0 | 143 | | return new UserTaskCapabilities(task.Id, task.Revision, summary.AllowedActions, |
| | 0 | 144 | | await policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellationToken), |
| | 0 | 145 | | actor.IsManager && !actor.IsGuest); |
| | 0 | 146 | | } |
| | | 147 | | |
| | | 148 | | public static UserTaskEventSummary ToEventSummary(UserTaskEvent @event) => |
| | 2 | 149 | | new(@event.Id, @event.EventType, @event.Reason, @event.OccurredAt, @event.Actor?.DisplayName); |
| | | 150 | | |
| | 3 | 151 | | private static UserTaskWorkflowContext ToWorkflowContext(UserTask task) => new() |
| | 3 | 152 | | { |
| | 3 | 153 | | DefinitionId = NullIfEmpty(task.WorkflowDefinitionId), |
| | 3 | 154 | | DefinitionName = task.WorkflowDefinitionName, |
| | 3 | 155 | | DefinitionVersion = task.WorkflowDefinitionVersion, |
| | 3 | 156 | | InstanceId = NullIfEmpty(task.WorkflowInstanceId), |
| | 3 | 157 | | InstanceReference = task.WorkflowInstanceReference |
| | 3 | 158 | | }; |
| | | 159 | | |
| | | 160 | | private static UserTaskFormProjection? ToFormProjection(UserTask task, IReadOnlyCollection<UserTaskFormAction> actio |
| | | 161 | | { |
| | 5 | 162 | | if (task.PinnedForm is not { } form) |
| | 4 | 163 | | return null; |
| | | 164 | | |
| | 4 | 165 | | var fields = form.Fields.Select(descriptor => new UserTaskFormField |
| | 4 | 166 | | { |
| | 4 | 167 | | Key = descriptor.Key, |
| | 4 | 168 | | Label = descriptor.Label, |
| | 4 | 169 | | Type = descriptor.Type, |
| | 4 | 170 | | Required = descriptor.Required, |
| | 4 | 171 | | Masked = descriptor.Masked, |
| | 4 | 172 | | CanReveal = descriptor.Masked && descriptor.CanReveal && canReadProtected, |
| | 4 | 173 | | // A masked value never rides along with the form. It is disclosed only through the explicit, |
| | 4 | 174 | | // audited reveal command, so an accidental log or screenshot of the detail response is inert. |
| | 4 | 175 | | Value = canReadProtected && !descriptor.Masked ? ReadFieldValue(task.TaskData, descriptor.Key) : null |
| | 4 | 176 | | }).ToArray(); |
| | | 177 | | |
| | 1 | 178 | | return new UserTaskFormProjection |
| | 1 | 179 | | { |
| | 1 | 180 | | Provider = form.Requested.ProviderName, |
| | 1 | 181 | | Key = form.Requested.Key, |
| | 1 | 182 | | Version = form.PinnedVersion, |
| | 1 | 183 | | Fields = fields, |
| | 1 | 184 | | Actions = actions |
| | 1 | 185 | | }; |
| | | 186 | | } |
| | | 187 | | |
| | | 188 | | internal static JsonElement? ReadFieldValue(JsonElement? data, string key) => |
| | 3 | 189 | | data is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(key, out var value) ? value.Clone( |
| | | 190 | | |
| | | 191 | | private static string? DescribeCandidates(UserTask task) |
| | | 192 | | { |
| | 7 | 193 | | var users = task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot |
| | 0 | 194 | | ? task.SnapshotMembers.Count(x => x.Type == UserTaskParticipantType.User) |
| | 7 | 195 | | : task.CandidateUsers.Count; |
| | 7 | 196 | | var groups = task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot |
| | 7 | 197 | | ? task.SnapshotGroups.Count |
| | 7 | 198 | | : task.CandidateGroups.Count; |
| | 7 | 199 | | if (users == 0 && groups == 0) |
| | 0 | 200 | | return null; |
| | | 201 | | |
| | | 202 | | // Counts only: disclosing which peers are eligible would let any candidate enumerate the others. |
| | 7 | 203 | | var parts = new List<string>(2); |
| | 7 | 204 | | if (users > 0) |
| | 7 | 205 | | parts.Add(users == 1 ? "1 user" : $"{users} users"); |
| | 7 | 206 | | if (groups > 0) |
| | 0 | 207 | | parts.Add(groups == 1 ? "1 group" : $"{groups} groups"); |
| | 7 | 208 | | return string.Join(", ", parts); |
| | | 209 | | } |
| | | 210 | | |
| | 20 | 211 | | private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; |
| | | 212 | | } |