< Summary

Information
Class: Elsa.UserTasks.Persistence.EFCore.Repositories.UserTaskSecretHashing
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
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 215
Line coverage: 100%
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
Hash(...)100%11100%
CreateToken()100%11100%

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{
 3218    public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
 19
 1520    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>
 100public sealed class EFCoreUserTaskInvitationOutbox(
 101    Store<UserTasksElsaDbContext, UserTaskInvitationDeliveryRecord> store,
 102    IDataProtectionProvider dataProtectionProvider,
 103    ISystemClock clock,
 104    IOptions<UserTasksOptions> options) : IUserTaskInvitationOutbox
 105{
 106    private static readonly JsonSerializerOptions MetadataJsonOptions = new() { PropertyNameCaseInsensitive = true };
 107
 108    private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelive
 109
 110    public async Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
 111    {
 112        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 113        dbContext.UserTaskInvitationDeliveries.Add(new()
 114        {
 115            Id = delivery.Id,
 116            TenantId = delivery.TenantId,
 117            TaskId = delivery.TaskId,
 118            InvitationId = delivery.InvitationId,
 119            DispatcherProvider = delivery.DispatcherName,
 120            EncryptedToken = _protector.Protect(delivery.Token),
 121            DeliveryMetadataJson = delivery.Recipient == null ? null : JsonSerializer.Serialize(new { delivery.Recipient
 122            Status = UserTaskPersistenceDeliveryStatus.Pending,
 123            AvailableAt = delivery.NotBefore ?? clock.UtcNow,
 124            ExpiresAt = delivery.ExpiresAt,
 125            CreatedAt = clock.UtcNow
 126        });
 127        await dbContext.SaveChangesAsync(cancellationToken);
 128    }
 129
 130    public async Task<IReadOnlyCollection<UserTaskInvitationDelivery>> DequeueDueAsync(int maxCount, CancellationToken c
 131    {
 132        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 133        var now = clock.UtcNow;
 134        // Expired secrets are dropped, never delivered late.
 135        await dbContext.UserTaskInvitationDeliveries.Where(x => x.ExpiresAt <= now).ExecuteDeleteAsync(cancellationToken
 136
 137        var rows = await dbContext.UserTaskInvitationDeliveries.AsNoTracking()
 138            .Where(x => x.Status == UserTaskPersistenceDeliveryStatus.Pending && x.AvailableAt <= now)
 139            .OrderBy(x => x.AvailableAt)
 140            .Take(Math.Max(1, maxCount))
 141            .ToListAsync(cancellationToken);
 142
 143        var deliveries = new List<UserTaskInvitationDelivery>(rows.Count);
 144        foreach (var row in rows)
 145        {
 146            string token;
 147            try
 148            {
 149                token = _protector.Unprotect(row.EncryptedToken);
 150            }
 151            catch (CryptographicException)
 152            {
 153                // A rotated or unavailable key makes the secret unrecoverable; drop it so a manager reissues.
 154                await dbContext.UserTaskInvitationDeliveries.Where(x => x.Id == row.Id).ExecuteDeleteAsync(cancellationT
 155                continue;
 156            }
 157
 158            deliveries.Add(new(row.Id, row.TenantId, row.TaskId, row.InvitationId, row.DispatcherProvider, token, row.Ex
 159            {
 160                // The recipient is the only address the host's dispatcher has to send the link to. Dropping
 161                // it here made every durably queued invitation undeliverable while still reporting success.
 162                Recipient = ReadRecipient(row.DeliveryMetadataJson),
 163                Attempt = row.Attempts,
 164                NotBefore = row.AvailableAt
 165            });
 166        }
 167
 168        return deliveries;
 169    }
 170
 171    private static string? ReadRecipient(string? metadataJson)
 172    {
 173        if (string.IsNullOrWhiteSpace(metadataJson))
 174            return null;
 175        try
 176        {
 177            return JsonSerializer.Deserialize<DeliveryMetadata>(metadataJson, MetadataJsonOptions)?.Recipient;
 178        }
 179        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.
 183            return null;
 184        }
 185    }
 186
 187    private sealed record DeliveryMetadata(string? Recipient);
 188
 189    public async Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default)
 190    {
 191        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 192        // Delivery succeeded, so the encrypted secret has no further purpose and is removed outright.
 193        await dbContext.UserTaskInvitationDeliveries.Where(x => x.Id == deliveryId).ExecuteDeleteAsync(cancellationToken
 194    }
 195
 196    public async Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken =
 197    {
 198        await using var dbContext = await store.CreateDbContextAsync(cancellationToken);
 199        var row = await dbContext.UserTaskInvitationDeliveries.FirstOrDefaultAsync(x => x.Id == deliveryId, cancellation
 200        if (row is null)
 201            return;
 202
 203        row.Attempts += 1;
 204        if (row.Attempts > options.Value.InvitationDeliveryRetryDelays.Count)
 205        {
 206            dbContext.UserTaskInvitationDeliveries.Remove(row);
 207        }
 208        else
 209        {
 210            row.AvailableAt = notBefore;
 211            row.LastErrorCode = "dispatch-failed";
 212        }
 213        await dbContext.SaveChangesAsync(cancellationToken);
 214    }
 215}

Methods/Properties

Hash(System.String)
CreateToken()