< Summary

Information
Class: Elsa.UserTasks.Services.NullUserTaskInvitationDispatcher
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 1
Coverable lines: 1
Total lines: 277
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
DispatchAsync(...)100%210%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using Elsa.Common;
 4using Elsa.UserTasks.Contracts;
 5using Elsa.UserTasks.Models;
 6using Elsa.UserTasks.Options;
 7using Elsa.Workflows;
 8using Microsoft.Extensions.Options;
 9
 10namespace 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>
 17public sealed class DefaultUserTaskInvitationService(
 18    IUserTaskRepository repository,
 19    IUserTaskAccessPolicy accessPolicy,
 20    IUserTaskInvitationOutbox outbox,
 21    IUserTaskInvitationVerifier verifier,
 22    IUserTaskGuestSessionIssuer sessionIssuer,
 23    IUserTaskNotificationSink notifications,
 24    IIdentityGenerator identityGenerator,
 25    ISystemClock clock,
 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    {
 33        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 34        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can
 35            return null;
 36        if (task.IsTerminal || request.ExpectedRevision != task.Revision || string.IsNullOrWhiteSpace(request.VerifierNa
 37            return null;
 38
 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.
 42        var definition = task.InvitationDefinitions.FirstOrDefault(x =>
 43            string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase)
 44            && x.AllowedActions.Count == actions.Length
 45            && x.AllowedActions.All(allowed => actions.Contains(allowed, StringComparer.OrdinalIgnoreCase)));
 46        if (definition == null || actions.Length == 0)
 47            return null;
 48
 49        var now = clock.UtcNow;
 50        var lifetime = request.Lifetime ?? definition.Lifetime ?? options.Value.DefaultInvitationLifetime;
 51        if (lifetime <= TimeSpan.Zero)
 52            return null;
 53        var expiresAt = now.Add(lifetime);
 54        if (task.DueAt is { } dueAt && dueAt < expiresAt)
 55            expiresAt = dueAt;
 56        if (expiresAt <= now)
 57            return null;
 58
 59        var token = Base64Url(RandomNumberGenerator.GetBytes(32));
 60        var tokenHash = HashToken(token);
 61        var siblingGroupId = task.Invitations.FirstOrDefault(x =>
 62            string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase)
 63            && x.Status is (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched))?.SiblingGroupId
 64            ?? identityGenerator.GenerateId();
 65        var invitation = new UserTaskInvitation(
 66            identityGenerator.GenerateId(), tenantId, taskId, request.Recipient, tokenHash,
 67            UserTaskInvitationStatus.Pending, now, expiresAt, request.VerifierName,
 68            SiblingGroupId: siblingGroupId)
 69        {
 70            // Pinned at issuance: a later workflow definition change cannot widen an outstanding link.
 71            AllowedActions = actions
 72        };
 73
 74        task.Invitations.Add(invitation);
 75        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1,
 76            "InvitationIssued", now, actor.Subject, request.OperationId));
 77        try
 78        {
 79            await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken);
 80        }
 81        catch (UserTaskRevisionConflictException)
 82        {
 83            return null;
 84        }
 85
 86        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 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.
 91        await outbox.EnqueueAsync(new UserTaskInvitationDelivery(
 92            identityGenerator.GenerateId(), tenantId, taskId, invitation.Id, definition.VerifierName, token, expiresAt)
 93        {
 94            Recipient = request.Recipient
 95        }, cancellationToken);
 96
 97        return new UserTaskInvitationIssueResult(ToSummary(invitation), request.OperationId);
 98    }
 99
 100    public async Task<IReadOnlyCollection<UserTaskInvitationSummary>?> ListAsync(string tenantId, string taskId, UserTas
 101    {
 102        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 103        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can
 104            return null;
 105        return task.Invitations.Select(ToSummary).ToArray();
 106    }
 107
 108    public async Task<bool> RevokeAsync(string tenantId, string taskId, string invitationId, int expectedRevision, UserT
 109    {
 110        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 111        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can
 112            return false;
 113        var existing = task.Invitations.FirstOrDefault(x => x.Id == invitationId);
 114        if (existing == null || existing.Status is UserTaskInvitationStatus.Expired)
 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.
 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.
 124        if (existing.Status is UserTaskInvitationStatus.Revoked)
 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.
 129        if (!await repository.TryMutateAsync(tenantId, taskId, expectedRevision, current =>
 130            {
 131                var invitation = current.Invitations.FirstOrDefault(x => x.Id == invitationId);
 132                if (invitation == null || invitation.Status is UserTaskInvitationStatus.Revoked or UserTaskInvitationSta
 133                    return false;
 134                var index = current.Invitations.IndexOf(invitation);
 135                current.Invitations[index] = invitation with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = cl
 136                current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, current.Revision 
 137                    "InvitationRevoked", clock.UtcNow, actor.Subject));
 138                return true;
 139            }, cancellationToken))
 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.
 146        await sessionIssuer.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken);
 147
 148        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken);
 149        if (committed != null)
 150            await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed
 151        return true;
 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.
 158        var resolved = await ResolveOpenInvitationAsync(token, cancellationToken);
 159        var bearerOnly = resolved is { } match && FindDefinition(match.Task, match.Invitation)?.BearerOnly == true;
 160        return bearerOnly
 161            ? new UserTaskInvitationChallengeDescriptor("bearer", "Open this task to continue.", RequiresCode: false)
 162            : new UserTaskInvitationChallengeDescriptor("code", "Enter the verification code you were sent to continue."
 163    }
 164
 165    public async Task<UserTaskInvitationVerificationResultWithSession> VerifyAsync(UserTaskInvitationChallenge challenge
 166    {
 167        if (await ResolveOpenInvitationAsync(challenge.Token, cancellationToken) is not { } resolved)
 168            return Failed();
 169
 170        var (task, invitation) = resolved;
 171        var definition = FindDefinition(task, invitation);
 172        var challengeResult = definition?.BearerOnly == true
 173            ? new UserTaskInvitationVerificationResult(true, Subject: null)
 174            : await verifier.VerifyAsync(challenge, cancellationToken);
 175        if (!challengeResult.Succeeded)
 176            return Failed();
 177
 178        var subject = new ParticipantReference(task.TenantId, "guest", UserTaskParticipantType.User,
 179            challengeResult.Subject ?? $"invitation:{invitation.Id}");
 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.
 184        var claimed = await repository.TryMutateAsync(task.TenantId, task.Id, task.Revision, current =>
 185        {
 186            var currentInvitation = current.Invitations.FirstOrDefault(x => x.Id == invitation.Id);
 187            if (currentInvitation == null || currentInvitation.ExpiresAt <= verifiedAt || currentInvitation.Status is no
 188                return false;
 189            if (current.IsTerminal || current.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTas
 190                return false;
 191
 192            current.Assignee = subject;
 193            current.AssignedAt = verifiedAt;
 194            current.Status = UserTaskStatus.Assigned;
 195            var index = current.Invitations.IndexOf(currentInvitation);
 196            current.Invitations[index] = currentInvitation with
 197            {
 198                Status = UserTaskInvitationStatus.Consumed,
 199                VerifiedAt = verifiedAt,
 200                ConsumedAt = verifiedAt
 201            };
 202            var revokedSiblings = 0;
 203            for (var i = 0; i < current.Invitations.Count; i++)
 204            {
 205                var sibling = current.Invitations[i];
 206                if (sibling.Id != currentInvitation.Id && sibling.SiblingGroupId == currentInvitation.SiblingGroupId && 
 207                {
 208                    current.Invitations[i] = sibling with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = verif
 209                    revokedSiblings++;
 210                }
 211            }
 212            // The challenge response and the secret itself are never written to the audit trail.
 213            current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), current.TenantId, current.Id, current.R
 214                "InvitationVerified", verifiedAt, subject, Metadata: new Dictionary<string, object?> { ["revokedSiblingC
 215            return true;
 216        }, cancellationToken);
 217        if (!claimed)
 218            return Failed();
 219
 220        var consumed = invitation with { Status = UserTaskInvitationStatus.Consumed, VerifiedAt = verifiedAt, ConsumedAt
 221        var session = await sessionIssuer.IssueAsync(consumed, subject, cancellationToken);
 222        if (!session.Succeeded)
 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.
 228        var settled = await repository.GetAsync(task.TenantId, task.Id, cancellationToken);
 229        if (settled?.Invitations.FirstOrDefault(x => x.Id == invitation.Id) is not { Status: UserTaskInvitationStatus.Co
 230        {
 231            await sessionIssuer.RevokeForInvitationAsync(task.TenantId, invitation.Id, cancellationToken);
 232            return Failed();
 233        }
 234
 235        return new UserTaskInvitationVerificationResultWithSession(true, task.Id, session.Token, session.ExpiresAt);
 236    }
 237
 238    private async Task<(UserTask Task, UserTaskInvitation Invitation)?> ResolveOpenInvitationAsync(string? token, Cancel
 239    {
 240        if (string.IsNullOrWhiteSpace(token))
 241            return null;
 242        var match = await repository.FindByInvitationTokenHashAsync(HashToken(token), cancellationToken);
 243        if (match is not { } found)
 244            return null;
 245        var invitation = found.Invitation;
 246        return invitation.ExpiresAt <= clock.UtcNow || invitation.Status is not (UserTaskInvitationStatus.Pending or Use
 247            ? null
 248            : found;
 249    }
 250
 251    private static UserTaskInvitationDefinition? FindDefinition(UserTask task, UserTaskInvitation invitation) =>
 252        task.InvitationDefinitions.FirstOrDefault(x => string.Equals(x.VerifierName, invitation.VerifierName, StringComp
 253
 254    private static UserTaskInvitationVerificationResultWithSession Failed() => new(false, FailureCode: GenericFailure);
 255
 256    private static UserTaskInvitationSummary ToSummary(UserTaskInvitation invitation) => new(
 257        invitation.Id, invitation.TaskId, invitation.Recipient, invitation.Status, invitation.IssuedAt, invitation.Expir
 258
 259    internal static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))
 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>
 264public sealed class NullUserTaskInvitationDispatcher : IUserTaskInvitationDispatcher
 265{
 0266    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>
 273public sealed class DefaultUserTaskInvitationVerifier : IUserTaskInvitationVerifier
 274{
 275    public Task<UserTaskInvitationVerificationResult> VerifyAsync(UserTaskInvitationChallenge challenge, CancellationTok
 276        Task.FromResult(new UserTaskInvitationVerificationResult(false, "challenge-required"));
 277}