< Summary

Information
Class: Elsa.UserTasks.Services.DefaultUserTaskInvitationService
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs
Line coverage
88%
Covered lines: 153
Uncovered lines: 19
Coverable lines: 172
Total lines: 277
Line coverage: 88.9%
Branch coverage
73%
Covered branches: 91
Total branches: 124
Branch coverage: 73.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
IssueAsync()68.75%343286.66%
ListAsync()0%2040%
RevokeAsync()85.71%141488.88%
DescribeAsync()33.33%66100%
VerifyAsync()80%202093.75%
ResolveOpenInvitationAsync()90%101090%
FindDefinition(...)100%11100%
Failed()100%11100%
ToSummary(...)100%11100%
HashToken(...)100%11100%
Base64Url(...)100%11100%

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>
 8817public sealed class DefaultUserTaskInvitationService(
 8818    IUserTaskRepository repository,
 8819    IUserTaskAccessPolicy accessPolicy,
 8820    IUserTaskInvitationOutbox outbox,
 8821    IUserTaskInvitationVerifier verifier,
 8822    IUserTaskGuestSessionIssuer sessionIssuer,
 8823    IUserTaskNotificationSink notifications,
 8824    IIdentityGenerator identityGenerator,
 8825    ISystemClock clock,
 8826    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    {
 3933        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 3934        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can
 135            return null;
 3836        if (task.IsTerminal || request.ExpectedRevision != task.Revision || string.IsNullOrWhiteSpace(request.VerifierNa
 037            return null;
 38
 7739        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.
 3842        var definition = task.InvitationDefinitions.FirstOrDefault(x =>
 7743            string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase)
 7744            && x.AllowedActions.Count == actions.Length
 11445            && x.AllowedActions.All(allowed => actions.Contains(allowed, StringComparer.OrdinalIgnoreCase)));
 3846        if (definition == null || actions.Length == 0)
 247            return null;
 48
 3649        var now = clock.UtcNow;
 3650        var lifetime = request.Lifetime ?? definition.Lifetime ?? options.Value.DefaultInvitationLifetime;
 3651        if (lifetime <= TimeSpan.Zero)
 052            return null;
 3653        var expiresAt = now.Add(lifetime);
 3654        if (task.DueAt is { } dueAt && dueAt < expiresAt)
 055            expiresAt = dueAt;
 3656        if (expiresAt <= now)
 057            return null;
 58
 3659        var token = Base64Url(RandomNumberGenerator.GetBytes(32));
 3660        var tokenHash = HashToken(token);
 3661        var siblingGroupId = task.Invitations.FirstOrDefault(x =>
 262            string.Equals(x.VerifierName, request.VerifierName, StringComparison.OrdinalIgnoreCase)
 263            && x.Status is (UserTaskInvitationStatus.Pending or UserTaskInvitationStatus.Dispatched))?.SiblingGroupId
 3664            ?? identityGenerator.GenerateId();
 3665        var invitation = new UserTaskInvitation(
 3666            identityGenerator.GenerateId(), tenantId, taskId, request.Recipient, tokenHash,
 3667            UserTaskInvitationStatus.Pending, now, expiresAt, request.VerifierName,
 3668            SiblingGroupId: siblingGroupId)
 3669        {
 3670            // Pinned at issuance: a later workflow definition change cannot widen an outstanding link.
 3671            AllowedActions = actions
 3672        };
 73
 3674        task.Invitations.Add(invitation);
 3675        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1,
 3676            "InvitationIssued", now, actor.Subject, request.OperationId));
 77        try
 78        {
 3679            await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken);
 3680        }
 081        catch (UserTaskRevisionConflictException)
 82        {
 083            return null;
 84        }
 85
 3686        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 3687        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.
 3691        await outbox.EnqueueAsync(new UserTaskInvitationDelivery(
 3692            identityGenerator.GenerateId(), tenantId, taskId, invitation.Id, definition.VerifierName, token, expiresAt)
 3693        {
 3694            Recipient = request.Recipient
 3695        }, cancellationToken);
 96
 3697        return new UserTaskInvitationIssueResult(ToSummary(invitation), request.OperationId);
 3998    }
 99
 100    public async Task<IReadOnlyCollection<UserTaskInvitationSummary>?> ListAsync(string tenantId, string taskId, UserTas
 101    {
 0102        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 0103        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can
 0104            return null;
 0105        return task.Invitations.Select(ToSummary).ToArray();
 0106    }
 107
 108    public async Task<bool> RevokeAsync(string tenantId, string taskId, string invitationId, int expectedRevision, UserT
 109    {
 21110        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 21111        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, can
 0112            return false;
 43113        var existing = task.Invitations.FirstOrDefault(x => x.Id == invitationId);
 21114        if (existing == null || existing.Status is UserTaskInvitationStatus.Expired)
 1115            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.
 20120        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.
 17124        if (existing.Status is UserTaskInvitationStatus.Revoked)
 3125            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.
 14129        if (!await repository.TryMutateAsync(tenantId, taskId, expectedRevision, current =>
 14130            {
 29131                var invitation = current.Invitations.FirstOrDefault(x => x.Id == invitationId);
 14132                if (invitation == null || invitation.Status is UserTaskInvitationStatus.Revoked or UserTaskInvitationSta
 0133                    return false;
 14134                var index = current.Invitations.IndexOf(invitation);
 14135                current.Invitations[index] = invitation with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = cl
 14136                current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, current.Revision 
 14137                    "InvitationRevoked", clock.UtcNow, actor.Subject));
 14138                return true;
 14139            }, cancellationToken))
 0140            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.
 14146        await sessionIssuer.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken);
 147
 12148        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken);
 12149        if (committed != null)
 12150            await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed
 12151        return true;
 16152    }
 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.
 2158        var resolved = await ResolveOpenInvitationAsync(token, cancellationToken);
 2159        var bearerOnly = resolved is { } match && FindDefinition(match.Task, match.Invitation)?.BearerOnly == true;
 2160        return bearerOnly
 2161            ? new UserTaskInvitationChallengeDescriptor("bearer", "Open this task to continue.", RequiresCode: false)
 2162            : new UserTaskInvitationChallengeDescriptor("code", "Enter the verification code you were sent to continue."
 2163    }
 164
 165    public async Task<UserTaskInvitationVerificationResultWithSession> VerifyAsync(UserTaskInvitationChallenge challenge
 166    {
 35167        if (await ResolveOpenInvitationAsync(challenge.Token, cancellationToken) is not { } resolved)
 4168            return Failed();
 169
 31170        var (task, invitation) = resolved;
 31171        var definition = FindDefinition(task, invitation);
 31172        var challengeResult = definition?.BearerOnly == true
 31173            ? new UserTaskInvitationVerificationResult(true, Subject: null)
 31174            : await verifier.VerifyAsync(challenge, cancellationToken);
 31175        if (!challengeResult.Succeeded)
 1176            return Failed();
 177
 30178        var subject = new ParticipantReference(task.TenantId, "guest", UserTaskParticipantType.User,
 30179            challengeResult.Subject ?? $"invitation:{invitation.Id}");
 30180        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.
 30184        var claimed = await repository.TryMutateAsync(task.TenantId, task.Id, task.Revision, current =>
 30185        {
 60186            var currentInvitation = current.Invitations.FirstOrDefault(x => x.Id == invitation.Id);
 30187            if (currentInvitation == null || currentInvitation.ExpiresAt <= verifiedAt || currentInvitation.Status is no
 0188                return false;
 30189            if (current.IsTerminal || current.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTas
 0190                return false;
 30191
 30192            current.Assignee = subject;
 30193            current.AssignedAt = verifiedAt;
 30194            current.Status = UserTaskStatus.Assigned;
 30195            var index = current.Invitations.IndexOf(currentInvitation);
 30196            current.Invitations[index] = currentInvitation with
 30197            {
 30198                Status = UserTaskInvitationStatus.Consumed,
 30199                VerifiedAt = verifiedAt,
 30200                ConsumedAt = verifiedAt
 30201            };
 30202            var revokedSiblings = 0;
 122203            for (var i = 0; i < current.Invitations.Count; i++)
 30204            {
 31205                var sibling = current.Invitations[i];
 31206                if (sibling.Id != currentInvitation.Id && sibling.SiblingGroupId == currentInvitation.SiblingGroupId && 
 30207                {
 1208                    current.Invitations[i] = sibling with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = verif
 1209                    revokedSiblings++;
 30210                }
 30211            }
 30212            // The challenge response and the secret itself are never written to the audit trail.
 30213            current.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), current.TenantId, current.Id, current.R
 30214                "InvitationVerified", verifiedAt, subject, Metadata: new Dictionary<string, object?> { ["revokedSiblingC
 30215            return true;
 30216        }, cancellationToken);
 30217        if (!claimed)
 0218            return Failed();
 219
 30220        var consumed = invitation with { Status = UserTaskInvitationStatus.Consumed, VerifiedAt = verifiedAt, ConsumedAt
 30221        var session = await sessionIssuer.IssueAsync(consumed, subject, cancellationToken);
 30222        if (!session.Succeeded)
 0223            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.
 30228        var settled = await repository.GetAsync(task.TenantId, task.Id, cancellationToken);
 60229        if (settled?.Invitations.FirstOrDefault(x => x.Id == invitation.Id) is not { Status: UserTaskInvitationStatus.Co
 230        {
 3231            await sessionIssuer.RevokeForInvitationAsync(task.TenantId, invitation.Id, cancellationToken);
 3232            return Failed();
 233        }
 234
 27235        return new UserTaskInvitationVerificationResultWithSession(true, task.Id, session.Token, session.ExpiresAt);
 35236    }
 237
 238    private async Task<(UserTask Task, UserTaskInvitation Invitation)?> ResolveOpenInvitationAsync(string? token, Cancel
 239    {
 37240        if (string.IsNullOrWhiteSpace(token))
 0241            return null;
 37242        var match = await repository.FindByInvitationTokenHashAsync(HashToken(token), cancellationToken);
 37243        if (match is not { } found)
 3244            return null;
 34245        var invitation = found.Invitation;
 34246        return invitation.ExpiresAt <= clock.UtcNow || invitation.Status is not (UserTaskInvitationStatus.Pending or Use
 34247            ? null
 34248            : found;
 37249    }
 250
 251    private static UserTaskInvitationDefinition? FindDefinition(UserTask task, UserTaskInvitation invitation) =>
 62252        task.InvitationDefinitions.FirstOrDefault(x => string.Equals(x.VerifierName, invitation.VerifierName, StringComp
 253
 8254    private static UserTaskInvitationVerificationResultWithSession Failed() => new(false, FailureCode: GenericFailure);
 255
 36256    private static UserTaskInvitationSummary ToSummary(UserTaskInvitation invitation) => new(
 36257        invitation.Id, invitation.TaskId, invitation.Recipient, invitation.Status, invitation.IssuedAt, invitation.Expir
 258
 158259    internal static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))
 73260    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{
 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>
 273public sealed class DefaultUserTaskInvitationVerifier : IUserTaskInvitationVerifier
 274{
 275    public Task<UserTaskInvitationVerificationResult> VerifyAsync(UserTaskInvitationChallenge challenge, CancellationTok
 276        Task.FromResult(new UserTaskInvitationVerificationResult(false, "challenge-required"));
 277}