| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | using Elsa.Common; |
| | | 4 | | using Elsa.UserTasks.Contracts; |
| | | 5 | | using Elsa.UserTasks.Models; |
| | | 6 | | using Elsa.UserTasks.Options; |
| | | 7 | | using Elsa.Workflows; |
| | | 8 | | using Microsoft.Extensions.Options; |
| | | 9 | | |
| | | 10 | | namespace Elsa.UserTasks.Services; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Implements the invitation protocol. Secrets never leave this service in plaintext except across the |
| | | 14 | | /// dispatcher boundary: only a SHA-256 hash is persisted on the task, and verification resolves the owning |
| | | 15 | | /// task by that hash so the flow works across restarts and behind any persistence provider. |
| | | 16 | | /// </summary> |
| | 88 | 17 | | public sealed class DefaultUserTaskInvitationService( |
| | 88 | 18 | | IUserTaskRepository repository, |
| | 88 | 19 | | IUserTaskAccessPolicy accessPolicy, |
| | 88 | 20 | | IUserTaskInvitationOutbox outbox, |
| | 88 | 21 | | IUserTaskInvitationVerifier verifier, |
| | 88 | 22 | | IUserTaskGuestSessionIssuer sessionIssuer, |
| | 88 | 23 | | IUserTaskNotificationSink notifications, |
| | 88 | 24 | | IIdentityGenerator identityGenerator, |
| | 88 | 25 | | ISystemClock clock, |
| | 88 | 26 | | IOptions<UserTasksOptions> options) : IUserTaskInvitationService |
| | | 27 | | { |
| | | 28 | | /// <summary>The single public failure code. Callers must not be able to tell these cases apart.</summary> |
| | | 29 | | private const string GenericFailure = "invitation-unavailable"; |
| | | 30 | | |
| | | 31 | | public async Task<UserTaskInvitationIssueResult?> IssueAsync(string tenantId, string taskId, UserTaskInvitationIssue |
| | | 32 | | { |
| | 39 | 33 | | var task = await repository.GetAsync(tenantId, taskId, cancellationToken); |
| | 39 | 34 | | if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can |
| | 1 | 35 | | return null; |
| | 38 | 36 | | if (task.IsTerminal || request.ExpectedRevision != task.Revision || string.IsNullOrWhiteSpace(request.VerifierNa |
| | 0 | 37 | | return null; |
| | | 38 | | |
| | 77 | 39 | | var actions = request.AllowedActions.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIg |
| | | 40 | | // Match the requested action set exactly against one of the activity's invitation definitions. |
| | | 41 | | // A manager cannot broaden a guest link beyond what the workflow designer materialized. |
| | 38 | 42 | | var definition = task.InvitationDefinitions.FirstOrDefault(x => |
| | 77 | 43 | | string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase) |
| | 77 | 44 | | && x.AllowedActions.Count == actions.Length |
| | 114 | 45 | | && x.AllowedActions.All(allowed => actions.Contains(allowed, StringComparer.OrdinalIgnoreCase))); |
| | 38 | 46 | | if (definition == null || actions.Length == 0) |
| | 2 | 47 | | return null; |
| | | 48 | | |
| | 36 | 49 | | var now = clock.UtcNow; |
| | 36 | 50 | | var lifetime = request.Lifetime ?? definition.Lifetime ?? options.Value.DefaultInvitationLifetime; |
| | 36 | 51 | | if (lifetime <= TimeSpan.Zero) |
| | 0 | 52 | | return null; |
| | 36 | 53 | | var expiresAt = now.Add(lifetime); |
| | 36 | 54 | | if (task.DueAt is { } dueAt && dueAt < expiresAt) |
| | 0 | 55 | | expiresAt = dueAt; |
| | 36 | 56 | | if (expiresAt <= now) |
| | 0 | 57 | | return null; |
| | | 58 | | |
| | 36 | 59 | | var token = Base64Url(RandomNumberGenerator.GetBytes(32)); |
| | 36 | 60 | | var tokenHash = HashToken(token); |
| | 36 | 61 | | var siblingGroupId = task.Invitations.FirstOrDefault(x => |
| | 2 | 62 | | string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase) |
| | 2 | 63 | | && x.Status is (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched))?.SiblingGroupId |
| | 36 | 64 | | ?? identityGenerator.GenerateId(); |
| | 36 | 65 | | var invitation = new UserTaskInvitation( |
| | 36 | 66 | | identityGenerator.GenerateId(), tenantId, taskId, request.Recipient, tokenHash, |
| | 36 | 67 | | UserTaskInvitationStatus.Pending, now, expiresAt, request.VerifierName, |
| | 36 | 68 | | SiblingGroupId: siblingGroupId) |
| | 36 | 69 | | { |
| | 36 | 70 | | // Pinned at issuance: a later workflow definition change cannot widen an outstanding link. |
| | 36 | 71 | | AllowedActions = actions |
| | 36 | 72 | | }; |
| | | 73 | | |
| | 36 | 74 | | task.Invitations.Add(invitation); |
| | 36 | 75 | | task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, |
| | 36 | 76 | | "InvitationIssued", now, actor.Subject, request.OperationId)); |
| | | 77 | | try |
| | | 78 | | { |
| | 36 | 79 | | await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken); |
| | 36 | 80 | | } |
| | 0 | 81 | | catch (UserTaskRevisionConflictException) |
| | | 82 | | { |
| | 0 | 83 | | return null; |
| | | 84 | | } |
| | | 85 | | |
| | 36 | 86 | | var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task; |
| | 36 | 87 | | await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed.Rev |
| | | 88 | | |
| | | 89 | | // The raw token crosses only the dispatcher boundary. It is parked in the encrypted outbox first so |
| | | 90 | | // a dispatcher failure can be retried without ever re-deriving the secret or returning it to the API. |
| | 36 | 91 | | await outbox.EnqueueAsync(new UserTaskInvitationDelivery( |
| | 36 | 92 | | identityGenerator.GenerateId(), tenantId, taskId, invitation.Id, definition.VerifierName, token, expiresAt) |
| | 36 | 93 | | { |
| | 36 | 94 | | Recipient = request.Recipient |
| | 36 | 95 | | }, cancellationToken); |
| | | 96 | | |
| | 36 | 97 | | return new UserTaskInvitationIssueResult(ToSummary(invitation), request.OperationId); |
| | 39 | 98 | | } |
| | | 99 | | |
| | | 100 | | public async Task<IReadOnlyCollection<UserTaskInvitationSummary>?> ListAsync(string tenantId, string taskId, UserTas |
| | | 101 | | { |
| | 0 | 102 | | var task = await repository.GetAsync(tenantId, taskId, cancellationToken); |
| | 0 | 103 | | if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can |
| | 0 | 104 | | return null; |
| | 0 | 105 | | return task.Invitations.Select(ToSummary).ToArray(); |
| | 0 | 106 | | } |
| | | 107 | | |
| | | 108 | | public async Task<bool> RevokeAsync(string tenantId, string taskId, string invitationId, int expectedRevision, UserT |
| | | 109 | | { |
| | 21 | 110 | | var task = await repository.GetAsync(tenantId, taskId, cancellationToken); |
| | 21 | 111 | | if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can |
| | 0 | 112 | | return false; |
| | 43 | 113 | | var existing = task.Invitations.FirstOrDefault(x => x.Id == invitationId); |
| | 21 | 114 | | if (existing == null || existing.Status is UserTaskInvitationStatus.Expired) |
| | 1 | 115 | | return false; |
| | | 116 | | |
| | | 117 | | // Swept on both sides of the commit, and both sides are load-bearing. This first sweep runs before |
| | | 118 | | // anything is committed, so a session-store failure leaves the invitation revocable and a retry |
| | | 119 | | // repairs it rather than stranding a live credential behind a guard that rejects the retry. |
| | 20 | 120 | | await sessionIssuer.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken); |
| | | 121 | | |
| | | 122 | | // Already revoked: the sweep above was the only work left, so a retry succeeds idempotently |
| | | 123 | | // instead of reporting a failure the caller cannot act on. |
| | 17 | 124 | | if (existing.Status is UserTaskInvitationStatus.Revoked) |
| | 3 | 125 | | return true; |
| | | 126 | | |
| | | 127 | | // A consumed invitation is precisely the case worth revoking: consuming it is what issued the guest |
| | | 128 | | // session, so refusing here would leave a live credential that no manager could withdraw. |
| | 14 | 129 | | if (!await repository.TryMutateAsync(tenantId, taskId, expectedRevision, current => |
| | 14 | 130 | | { |
| | 29 | 131 | | var invitation = current.Invitations.FirstOrDefault(x => x.Id == invitationId); |
| | 14 | 132 | | if (invitation == null || invitation.Status is UserTaskInvitationStatus.Revoked or UserTaskInvitationSta |
| | 0 | 133 | | return false; |
| | 14 | 134 | | var index = current.Invitations.IndexOf(invitation); |
| | 14 | 135 | | current.Invitations[index] = invitation with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = cl |
| | 14 | 136 | | current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, current.Revision |
| | 14 | 137 | | "InvitationRevoked", clock.UtcNow, actor.Subject)); |
| | 14 | 138 | | return true; |
| | 14 | 139 | | }, cancellationToken)) |
| | 0 | 140 | | return false; |
| | | 141 | | |
| | | 142 | | // The second sweep closes the mirror window: a concurrent verification can issue a session after the |
| | | 143 | | // first sweep and still read Consumed before this commit lands. Anything issued in that window is |
| | | 144 | | // caught here, and any verification that issues after the commit sees the revoked state at its own |
| | | 145 | | // settled-state check and withdraws its own credential. |
| | 14 | 146 | | await sessionIssuer.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken); |
| | | 147 | | |
| | 12 | 148 | | var committed = await repository.GetAsync(tenantId, taskId, cancellationToken); |
| | 12 | 149 | | if (committed != null) |
| | 12 | 150 | | await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed |
| | 12 | 151 | | return true; |
| | 16 | 152 | | } |
| | | 153 | | |
| | | 154 | | public async Task<UserTaskInvitationChallengeDescriptor> DescribeAsync(string token, CancellationToken cancellationT |
| | | 155 | | { |
| | | 156 | | // Copy is identical for every token. Only the challenge shape differs, and only for a token the |
| | | 157 | | // caller already holds, so this cannot be used to probe whether an unknown token exists. |
| | 2 | 158 | | var resolved = await ResolveOpenInvitationAsync(token, cancellationToken); |
| | 2 | 159 | | var bearerOnly = resolved is { } match && FindDefinition(match.Task, match.Invitation)?.BearerOnly == true; |
| | 2 | 160 | | return bearerOnly |
| | 2 | 161 | | ? new UserTaskInvitationChallengeDescriptor("bearer", "Open this task to continue.", RequiresCode: false) |
| | 2 | 162 | | : new UserTaskInvitationChallengeDescriptor("code", "Enter the verification code you were sent to continue." |
| | 2 | 163 | | } |
| | | 164 | | |
| | | 165 | | public async Task<UserTaskInvitationVerificationResultWithSession> VerifyAsync(UserTaskInvitationChallenge challenge |
| | | 166 | | { |
| | 35 | 167 | | if (await ResolveOpenInvitationAsync(challenge.Token, cancellationToken) is not { } resolved) |
| | 4 | 168 | | return Failed(); |
| | | 169 | | |
| | 31 | 170 | | var (task, invitation) = resolved; |
| | 31 | 171 | | var definition = FindDefinition(task, invitation); |
| | 31 | 172 | | var challengeResult = definition?.BearerOnly == true |
| | 31 | 173 | | ? new UserTaskInvitationVerificationResult(true, Subject: null) |
| | 31 | 174 | | : await verifier.VerifyAsync(challenge, cancellationToken); |
| | 31 | 175 | | if (!challengeResult.Succeeded) |
| | 1 | 176 | | return Failed(); |
| | | 177 | | |
| | 30 | 178 | | var subject = new ParticipantReference(task.TenantId, "guest", UserTaskParticipantType.User, |
| | 30 | 179 | | challengeResult.Subject ?? $"invitation:{invitation.Id}"); |
| | 30 | 180 | | var verifiedAt = clock.UtcNow; |
| | | 181 | | |
| | | 182 | | // Claiming, consuming, and sibling revocation happen inside one compare-and-swap. A second holder |
| | | 183 | | // racing the same sibling group therefore loses and receives the same generic failure. |
| | 30 | 184 | | var claimed = await repository.TryMutateAsync(task.TenantId, task.Id, task.Revision, current => |
| | 30 | 185 | | { |
| | 60 | 186 | | var currentInvitation = current.Invitations.FirstOrDefault(x => x.Id == invitation.Id); |
| | 30 | 187 | | if (currentInvitation == null || currentInvitation.ExpiresAt <= verifiedAt || currentInvitation.Status is no |
| | 0 | 188 | | return false; |
| | 30 | 189 | | if (current.IsTerminal || current.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTas |
| | 0 | 190 | | return false; |
| | 30 | 191 | | |
| | 30 | 192 | | current.Assignee = subject; |
| | 30 | 193 | | current.AssignedAt = verifiedAt; |
| | 30 | 194 | | current.Status = UserTaskStatus.Assigned; |
| | 30 | 195 | | var index = current.Invitations.IndexOf(currentInvitation); |
| | 30 | 196 | | current.Invitations[index] = currentInvitation with |
| | 30 | 197 | | { |
| | 30 | 198 | | Status = UserTaskInvitationStatus.Consumed, |
| | 30 | 199 | | VerifiedAt = verifiedAt, |
| | 30 | 200 | | ConsumedAt = verifiedAt |
| | 30 | 201 | | }; |
| | 30 | 202 | | var revokedSiblings = 0; |
| | 122 | 203 | | for (var i = 0; i < current.Invitations.Count; i++) |
| | 30 | 204 | | { |
| | 31 | 205 | | var sibling = current.Invitations[i]; |
| | 31 | 206 | | if (sibling.Id != currentInvitation.Id && sibling.SiblingGroupId == currentInvitation.SiblingGroupId && |
| | 30 | 207 | | { |
| | 1 | 208 | | current.Invitations[i] = sibling with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = verif |
| | 1 | 209 | | revokedSiblings++; |
| | 30 | 210 | | } |
| | 30 | 211 | | } |
| | 30 | 212 | | // The challenge response and the secret itself are never written to the audit trail. |
| | 30 | 213 | | current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), current.TenantId, current.Id, current.R |
| | 30 | 214 | | "InvitationVerified", verifiedAt, subject, Metadata: new Dictionary<string, object?> { ["revokedSiblingC |
| | 30 | 215 | | return true; |
| | 30 | 216 | | }, cancellationToken); |
| | 30 | 217 | | if (!claimed) |
| | 0 | 218 | | return Failed(); |
| | | 219 | | |
| | 30 | 220 | | var consumed = invitation with { Status = UserTaskInvitationStatus.Consumed, VerifiedAt = verifiedAt, ConsumedAt |
| | 30 | 221 | | var session = await sessionIssuer.IssueAsync(consumed, subject, cancellationToken); |
| | 30 | 222 | | if (!session.Succeeded) |
| | 0 | 223 | | return Failed(); |
| | | 224 | | |
| | | 225 | | // A manager can revoke between the claim above and the session landing in the store, and that |
| | | 226 | | // revocation would find nothing to sweep. Re-read the committed invitation and withdraw the |
| | | 227 | | // credential we just issued if it is no longer the consumed one we verified. |
| | 30 | 228 | | var settled = await repository.GetAsync(task.TenantId, task.Id, cancellationToken); |
| | 60 | 229 | | if (settled?.Invitations.FirstOrDefault(x => x.Id == invitation.Id) is not { Status: UserTaskInvitationStatus.Co |
| | | 230 | | { |
| | 3 | 231 | | await sessionIssuer.RevokeForInvitationAsync(task.TenantId, invitation.Id, cancellationToken); |
| | 3 | 232 | | return Failed(); |
| | | 233 | | } |
| | | 234 | | |
| | 27 | 235 | | return new UserTaskInvitationVerificationResultWithSession(true, task.Id, session.Token, session.ExpiresAt); |
| | 35 | 236 | | } |
| | | 237 | | |
| | | 238 | | private async Task<(UserTask Task, UserTaskInvitation Invitation)?> ResolveOpenInvitationAsync(string? token, Cancel |
| | | 239 | | { |
| | 37 | 240 | | if (string.IsNullOrWhiteSpace(token)) |
| | 0 | 241 | | return null; |
| | 37 | 242 | | var match = await repository.FindByInvitationTokenHashAsync(HashToken(token), cancellationToken); |
| | 37 | 243 | | if (match is not { } found) |
| | 3 | 244 | | return null; |
| | 34 | 245 | | var invitation = found.Invitation; |
| | 34 | 246 | | return invitation.ExpiresAt <= clock.UtcNow || invitation.Status is not (UserTaskInvitationStatus.Pending or Use |
| | 34 | 247 | | ? null |
| | 34 | 248 | | : found; |
| | 37 | 249 | | } |
| | | 250 | | |
| | | 251 | | private static UserTaskInvitationDefinition? FindDefinition(UserTask task, UserTaskInvitation invitation) => |
| | 62 | 252 | | task.InvitationDefinitions.FirstOrDefault(x => string.Equals(x.VerifierName, invitation.VerifierName, StringComp |
| | | 253 | | |
| | 8 | 254 | | private static UserTaskInvitationVerificationResultWithSession Failed() => new(false, FailureCode: GenericFailure); |
| | | 255 | | |
| | 36 | 256 | | private static UserTaskInvitationSummary ToSummary(UserTaskInvitation invitation) => new( |
| | 36 | 257 | | invitation.Id, invitation.TaskId, invitation.Recipient, invitation.Status, invitation.IssuedAt, invitation.Expir |
| | | 258 | | |
| | 158 | 259 | | internal static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)) |
| | 73 | 260 | | internal static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Repla |
| | | 261 | | } |
| | | 262 | | |
| | | 263 | | /// <summary>A dispatcher that drops deliveries. Hosts replace it with an email, SMS, or webhook dispatcher.</summary> |
| | | 264 | | public sealed class NullUserTaskInvitationDispatcher : IUserTaskInvitationDispatcher |
| | | 265 | | { |
| | | 266 | | public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default) => Tas |
| | | 267 | | } |
| | | 268 | | |
| | | 269 | | /// <summary> |
| | | 270 | | /// The default verifier refuses every challenge. A host that enables guest invitations must register a real |
| | | 271 | | /// verifier; failing closed is preferable to accepting any bearer who holds a link. |
| | | 272 | | /// </summary> |
| | | 273 | | public sealed class DefaultUserTaskInvitationVerifier : IUserTaskInvitationVerifier |
| | | 274 | | { |
| | | 275 | | public Task<UserTaskInvitationVerificationResult> VerifyAsync(UserTaskInvitationChallenge challenge, CancellationTok |
| | | 276 | | Task.FromResult(new UserTaskInvitationVerificationResult(false, "challenge-required")); |
| | | 277 | | } |