< Summary

Information
Class: Elsa.UserTasks.Persistence.EFCore.Repositories.EFCoreUserTaskInvitationOutbox
Assembly: Elsa.UserTasks.Persistence.EFCore
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs
Line coverage
94%
Covered lines: 64
Uncovered lines: 4
Coverable lines: 68
Total lines: 215
Line coverage: 94.1%
Branch coverage
95%
Covered branches: 21
Total branches: 22
Branch coverage: 95.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
EnqueueAsync()100%66100%
DequeueDueAsync()100%4491.66%
ReadRecipient(...)75%5466.66%
get_Recipient()100%11100%
CompleteAsync()100%22100%
RescheduleAsync()100%66100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using System.Text.Json;
 4using System.Text.Json.Serialization;
 5using Elsa.Common;
 6using Elsa.Persistence.EFCore;
 7using Elsa.UserTasks.Contracts;
 8using Elsa.UserTasks.Models;
 9using Elsa.UserTasks.Options;
 10using Microsoft.AspNetCore.DataProtection;
 11using Microsoft.EntityFrameworkCore;
 12using Microsoft.Extensions.Options;
 13
 14namespace Elsa.UserTasks.Persistence.EFCore.Repositories;
 15
 16internal static class UserTaskSecretHashing
 17{
 18    public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
 19
 20    public static string CreateToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replac
 21}
 22
 23/// <summary>
 24/// Durable guest session store. Only the credential hash is written, and every read re-checks expiry and
 25/// revocation so a session cannot outlive its task across a restart or a failover.
 26/// </summary>
 27public sealed class EFCoreUserTaskGuestSessionIssuer(
 28    Store<UserTasksElsaDbContext, UserTaskGuestSessionRecord> store,
 29    ISystemClock clock,
 30    IOptions<UserTasksOptions> options) : IUserTaskGuestSessionIssuer
 31{
 32    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new Jso
 33
 34    public async Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, Cancel
 35    {
 36        var now = clock.UtcNow;
 37        var expiresAt = invitation.ExpiresAt <= now.Add(options.Value.GuestSessionLifetime) ? invitation.ExpiresAt : now
 38        if (expiresAt <= now)
 39            return new(false, FailureCode: "session-unavailable");
 40
 41        var token = UserTaskSecretHashing.CreateToken();
 42        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 43        dbContext.UserTaskGuestSessions.Add(new()
 44        {
 45            TenantId = invitation.TenantId,
 46            TaskId = invitation.TaskId,
 47            InvitationId = invitation.Id,
 48            SessionTokenHash = UserTaskSecretHashing.Hash(token),
 49            GuestParticipantJson = JsonSerializer.Serialize(subject, JsonOptions),
 50            CapabilitiesJson = JsonSerializer.Serialize(invitation.AllowedActions, JsonOptions),
 51            IssuedAt = now,
 52            ExpiresAt = expiresAt
 53        });
 54        await dbContext.SaveChangesAsync(cancellationToken);
 55        return new(true, token, expiresAt, TaskId: invitation.TaskId);
 56    }
 57
 58    public async Task<UserTaskGuestSession?> ResolveAsync(string credential, CancellationToken cancellationToken = defau
 59    {
 60        if (string.IsNullOrWhiteSpace(credential))
 61            return null;
 62
 63        var hash = UserTaskSecretHashing.Hash(credential);
 64        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 65        var now = clock.UtcNow;
 66        var row = await dbContext.UserTaskGuestSessions.AsNoTracking()
 67            .FirstOrDefaultAsync(x => x.SessionTokenHash == hash && x.RevokedAt == null && x.ExpiresAt > now, cancellati
 68        if (row is null)
 69            return null;
 70
 71        var subject = JsonSerializer.Deserialize<ParticipantReference>(row.GuestParticipantJson, JsonOptions);
 72        if (subject is null)
 73            return null;
 74
 75        var actions = JsonSerializer.Deserialize<List<string>>(row.CapabilitiesJson, JsonOptions) ?? [];
 76        return new(row.TenantId, row.TaskId, row.InvitationId, subject, actions, row.ExpiresAt);
 77    }
 78
 79    public async Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
 80    {
 81        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 82        await dbContext.UserTaskGuestSessions
 83            .Where(x => x.TenantId == tenantId && x.TaskId == taskId && x.RevokedAt == null)
 84            .ExecuteUpdateAsync(x => x.SetProperty(p => p.RevokedAt, clock.UtcNow), cancellationToken);
 85    }
 86
 87    public async Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken
 88    {
 89        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 90        await dbContext.UserTaskGuestSessions
 91            .Where(x => x.TenantId == tenantId && x.InvitationId == invitationId && x.RevokedAt == null)
 92            .ExecuteUpdateAsync(x => x.SetProperty(p => p.RevokedAt, clock.UtcNow), cancellationToken);
 93    }
 94}
 95
 96/// <summary>
 97/// Durable invitation-delivery outbox. Tokens are encrypted with ASP.NET Core Data Protection before they
 98/// reach the database, so a table dump never yields a usable invitation link.
 99/// </summary>
 1100public sealed class EFCoreUserTaskInvitationOutbox(
 1101    Store<UserTasksElsaDbContext, UserTaskInvitationDeliveryRecord> store,
 1102    IDataProtectionProvider dataProtectionProvider,
 1103    ISystemClock clock,
 1104    IOptions<UserTasksOptions> options) : IUserTaskInvitationOutbox
 105{
 1106    private static readonly JsonSerializerOptions MetadataJsonOptions = new() { PropertyNameCaseInsensitive = true };
 107
 1108    private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelive
 109
 110    public async Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
 111    {
 13112        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 13113        dbContext.UserTaskInvitationDeliveries.Add(new()
 13114        {
 13115            Id = delivery.Id,
 13116            TenantId = delivery.TenantId,
 13117            TaskId = delivery.TaskId,
 13118            InvitationId = delivery.InvitationId,
 13119            DispatcherProvider = delivery.DispatcherName,
 13120            EncryptedToken = _protector.Protect(delivery.Token),
 13121            DeliveryMetadataJson = delivery.Recipient == null ? null : JsonSerializer.Serialize(new { delivery.Recipient
 13122            Status = UserTaskPersistenceDeliveryStatus.Pending,
 13123            AvailableAt = delivery.NotBefore ?? clock.UtcNow,
 13124            ExpiresAt = delivery.ExpiresAt,
 13125            CreatedAt = clock.UtcNow
 13126        });
 13127        await dbContext.SaveChangesAsync(cancellationToken);
 13128    }
 129
 130    public async Task<IReadOnlyCollection<UserTaskInvitationDelivery>> DequeueDueAsync(int maxCount, CancellationToken c
 131    {
 16132        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 16133        var now = clock.UtcNow;
 134        // Expired secrets are dropped, never delivered late.
 16135        await dbContext.UserTaskInvitationDeliveries.Where(x => x.ExpiresAt <= now).ExecuteDeleteAsync(cancellationToken
 136
 16137        var rows = await dbContext.UserTaskInvitationDeliveries.AsNoTracking()
 16138            .Where(x => x.Status == UserTaskPersistenceDeliveryStatus.Pending && x.AvailableAt <= now)
 16139            .OrderBy(x => x.AvailableAt)
 16140            .Take(Math.Max(1, maxCount))
 16141            .ToListAsync(cancellationToken);
 142
 16143        var deliveries = new List<UserTaskInvitationDelivery>(rows.Count);
 110144        foreach (var row in rows)
 145        {
 146            string token;
 147            try
 148            {
 39149                token = _protector.Unprotect(row.EncryptedToken);
 39150            }
 151            catch (CryptographicException)
 152            {
 153                // A rotated or unavailable key makes the secret unrecoverable; drop it so a manager reissues.
 0154                await dbContext.UserTaskInvitationDeliveries.Where(x => x.Id == row.Id).ExecuteDeleteAsync(cancellationT
 0155                continue;
 156            }
 157
 39158            deliveries.Add(new(row.Id, row.TenantId, row.TaskId, row.InvitationId, row.DispatcherProvider, token, row.Ex
 39159            {
 39160                // The recipient is the only address the host's dispatcher has to send the link to. Dropping
 39161                // it here made every durably queued invitation undeliverable while still reporting success.
 39162                Recipient = ReadRecipient(row.DeliveryMetadataJson),
 39163                Attempt = row.Attempts,
 39164                NotBefore = row.AvailableAt
 39165            });
 166        }
 167
 16168        return deliveries;
 16169    }
 170
 171    private static string? ReadRecipient(string? metadataJson)
 172    {
 39173        if (string.IsNullOrWhiteSpace(metadataJson))
 35174            return null;
 175        try
 176        {
 4177            return JsonSerializer.Deserialize<DeliveryMetadata>(metadataJson, MetadataJsonOptions)?.Recipient;
 178        }
 0179        catch (JsonException)
 180        {
 181            // Metadata is routing information, not the secret. Unreadable metadata must not block a
 182            // delivery the dispatcher may still be able to route on its own.
 0183            return null;
 184        }
 4185    }
 186
 8187    private sealed record DeliveryMetadata(string? Recipient);
 188
 189    public async Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default)
 190    {
 5191        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 192        // Delivery succeeded, so the encrypted secret has no further purpose and is removed outright.
 5193        await dbContext.UserTaskInvitationDeliveries.Where(x => x.Id == deliveryId).ExecuteDeleteAsync(cancellationToken
 5194    }
 195
 196    public async Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken =
 197    {
 5198        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 5199        var row = await dbContext.UserTaskInvitationDeliveries.FirstOrDefaultAsync(x => x.Id == deliveryId, cancellation
 5200        if (row is null)
 201            return;
 202
 4203        row.Attempts += 1;
 4204        if (row.Attempts > options.Value.InvitationDeliveryRetryDelays.Count)
 205        {
 1206            dbContext.UserTaskInvitationDeliveries.Remove(row);
 207        }
 208        else
 209        {
 3210            row.AvailableAt = notBefore;
 3211            row.LastErrorCode = "dispatch-failed";
 212        }
 4213        await dbContext.SaveChangesAsync(cancellationToken);
 5214    }
 215}