< Summary

Information
Class: Elsa.UserTasks.Services.InMemoryUserTaskInvitationOutbox
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs
Line coverage
94%
Covered lines: 53
Uncovered lines: 3
Coverable lines: 56
Total lines: 242
Line coverage: 94.6%
Branch coverage
91%
Covered branches: 11
Total branches: 12
Branch coverage: 91.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
EnqueueAsync(...)100%22100%
DequeueDueAsync(...)100%44100%
CompleteAsync(...)100%11100%
RescheduleAsync(...)100%44100%
Unprotect(...)50%2276.92%
.ctor(...)100%11100%
get_Id()100%11100%
get_TenantId()100%11100%
get_TaskId()100%11100%
get_InvitationId()100%11100%
get_DispatcherName()100%11100%
get_Recipient()100%11100%
get_ProtectedToken()100%11100%
get_ExpiresAt()100%11100%
get_Attempt()100%11100%
get_NotBefore()100%11100%

File(s)

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

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Security.Cryptography;
 3using System.Text;
 4using System.Text.Json;
 5using Elsa.Authorization;
 6using Elsa.Common;
 7using Elsa.UserTasks.Contracts;
 8using Elsa.UserTasks.Models;
 9using Elsa.UserTasks.Options;
 10using Elsa.UserTasks.Permissions;
 11using Microsoft.AspNetCore.DataProtection;
 12using Microsoft.Extensions.Options;
 13
 14namespace Elsa.UserTasks.Services;
 15
 16/// <summary>
 17/// In-process guest session store. Only the SHA-256 hash of a credential is retained, so a memory dump or a
 18/// log of this structure cannot be replayed against the API. Hosts running more than one replica should
 19/// register a shared-store implementation instead.
 20/// </summary>
 21public sealed class InMemoryUserTaskGuestSessionIssuer(ISystemClock clock, IOptions<UserTasksOptions> options) : IUserTa
 22{
 23    private readonly ConcurrentDictionary<string, UserTaskGuestSession> _sessions = new(StringComparer.Ordinal);
 24
 25    public Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, Cancellation
 26    {
 27        var now = clock.UtcNow;
 28        // The session never outlives the invitation it came from, and never exceeds the host's own ceiling.
 29        var expiresAt = Min(invitation.ExpiresAt, now.Add(options.Value.GuestSessionLifetime));
 30        if (expiresAt <= now)
 31            return Task.FromResult(new GuestSessionResult(false, FailureCode: "session-unavailable"));
 32
 33        var token = DefaultUserTaskInvitationService.Base64Url(RandomNumberGenerator.GetBytes(32));
 34        _sessions[DefaultUserTaskInvitationService.HashToken(token)] = new UserTaskGuestSession(
 35            invitation.TenantId, invitation.TaskId, invitation.Id, subject, invitation.AllowedActions.ToArray(), expires
 36        return Task.FromResult(new GuestSessionResult(true, token, expiresAt, TaskId: invitation.TaskId));
 37    }
 38
 39    public Task<UserTaskGuestSession?> ResolveAsync(string credential, CancellationToken cancellationToken = default)
 40    {
 41        if (string.IsNullOrWhiteSpace(credential))
 42            return Task.FromResult<UserTaskGuestSession?>(null);
 43
 44        var hash = DefaultUserTaskInvitationService.HashToken(credential);
 45        if (!_sessions.TryGetValue(hash, out var session))
 46            return Task.FromResult<UserTaskGuestSession?>(null);
 47        if (session.ExpiresAt > clock.UtcNow)
 48            return Task.FromResult<UserTaskGuestSession?>(session);
 49
 50        _sessions.TryRemove(hash, out _);
 51        return Task.FromResult<UserTaskGuestSession?>(null);
 52    }
 53
 54    public Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
 55    {
 56        foreach (var entry in _sessions.Where(x => x.Value.TenantId == tenantId && x.Value.TaskId == taskId).ToArray())
 57            _sessions.TryRemove(entry.Key, out _);
 58        return Task.CompletedTask;
 59    }
 60
 61    public Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = def
 62    {
 63        foreach (var entry in _sessions.Where(x => x.Value.TenantId == tenantId && x.Value.InvitationId == invitationId)
 64            _sessions.TryRemove(entry.Key, out _);
 65        return Task.CompletedTask;
 66    }
 67
 68    private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right;
 69}
 70
 71/// <summary>
 72/// Fixed-window limiter for the anonymous invitation surface. Counters are keyed by a caller partition
 73/// (normally the remote address) and never by the token, so probing many tokens from one host still
 74/// consumes one budget.
 75/// </summary>
 76public sealed class SlidingWindowUserTaskInvitationRateLimiter(ISystemClock clock, IOptions<UserTasksOptions> options) :
 77{
 78    private readonly ConcurrentDictionary<string, Window> _windows = new(StringComparer.Ordinal);
 79
 80    public ValueTask<bool> TryAcquireAsync(string partitionKey, CancellationToken cancellationToken = default)
 81    {
 82        var settings = options.Value;
 83        if (settings.AnonymousRateLimit <= 0)
 84            return ValueTask.FromResult(true);
 85
 86        var now = clock.UtcNow;
 87        var allowed = true;
 88        _windows.AddOrUpdate(partitionKey,
 89            _ => new Window(now, 1),
 90            (_, existing) =>
 91            {
 92                if (now - existing.StartedAt >= settings.AnonymousRateLimitWindow)
 93                    return new Window(now, 1);
 94                allowed = existing.Count < settings.AnonymousRateLimit;
 95                return existing with { Count = existing.Count + 1 };
 96            });
 97
 98        // Opportunistic eviction keeps the dictionary bounded without a dedicated timer.
 99        if (_windows.Count > 10_000)
 100        {
 101            foreach (var stale in _windows.Where(x => now - x.Value.StartedAt >= settings.AnonymousRateLimitWindow).Take
 102                _windows.TryRemove(stale.Key, out _);
 103        }
 104
 105        return ValueTask.FromResult(allowed);
 106    }
 107
 108    private sealed record Window(DateTimeOffset StartedAt, int Count);
 109}
 110
 111/// <summary>
 112/// Transient outbox for invitation secrets awaiting delivery. Entries are encrypted with ASP.NET Core Data
 113/// Protection so the plaintext token exists only inside a dispatch attempt, and are dropped once delivery
 114/// succeeds, the retry schedule is exhausted, or the invitation expires.
 115/// </summary>
 72116public sealed class InMemoryUserTaskInvitationOutbox(
 72117    IDataProtectionProvider dataProtectionProvider,
 72118    ISystemClock clock,
 72119    IOptions<UserTasksOptions> options) : IUserTaskInvitationOutbox
 120{
 72121    private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelive
 72122    private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
 123
 124    public Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
 125    {
 41126        _entries[delivery.Id] = new Entry(
 41127            delivery.Id, delivery.TenantId, delivery.TaskId, delivery.InvitationId, delivery.DispatcherName,
 41128            delivery.Recipient, _protector.Protect(JsonSerializer.Serialize(delivery.Token)), delivery.ExpiresAt,
 41129            delivery.Attempt, delivery.NotBefore ?? clock.UtcNow);
 41130        return Task.CompletedTask;
 131    }
 132
 133    public Task<IReadOnlyCollection<UserTaskInvitationDelivery>> DequeueDueAsync(int maxCount, CancellationToken cancell
 134    {
 45135        var now = clock.UtcNow;
 153136        foreach (var expired in _entries.Where(x => x.Value.ExpiresAt <= now).ToArray())
 1137            _entries.TryRemove(expired.Key, out _);
 138
 45139        var due = _entries.Values
 60140            .Where(x => x.NotBefore <= now)
 58141            .OrderBy(x => x.NotBefore)
 45142            .Take(Math.Max(1, maxCount))
 45143            .Select(Unprotect)
 53144            .Where(x => x != null)
 53145            .Select(x => x!)
 45146            .ToArray();
 45147        return Task.FromResult<IReadOnlyCollection<UserTaskInvitationDelivery>>(due);
 148    }
 149
 150    public Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default)
 151    {
 30152        _entries.TryRemove(deliveryId, out _);
 30153        return Task.CompletedTask;
 154    }
 155
 156    public Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken = defau
 157    {
 8158        if (!_entries.TryGetValue(deliveryId, out var entry))
 1159            return Task.CompletedTask;
 160
 7161        var attempt = entry.Attempt + 1;
 162        // Abandon rather than retry forever: an undeliverable secret should expire, and a manager can reissue.
 7163        if (attempt > options.Value.InvitationDeliveryRetryDelays.Count)
 2164            _entries.TryRemove(deliveryId, out _);
 165        else
 5166            _entries[deliveryId] = entry with { Attempt = attempt, NotBefore = notBefore };
 7167        return Task.CompletedTask;
 168    }
 169
 170    private UserTaskInvitationDelivery? Unprotect(Entry entry)
 171    {
 172        try
 173        {
 53174            var token = JsonSerializer.Deserialize<string>(_protector.Unprotect(entry.ProtectedToken));
 53175            return token == null
 53176                ? null
 53177                : new UserTaskInvitationDelivery(entry.Id, entry.TenantId, entry.TaskId, entry.InvitationId, entry.Dispa
 53178                {
 53179                    Recipient = entry.Recipient,
 53180                    Attempt = entry.Attempt,
 53181                    NotBefore = entry.NotBefore
 53182                };
 183        }
 0184        catch (CryptographicException)
 185        {
 186            // A rotated or unavailable key makes the secret unrecoverable. Drop it instead of surfacing it.
 0187            _entries.TryRemove(entry.Id, out _);
 0188            return null;
 189        }
 53190    }
 191
 41192    private sealed record Entry(
 53193        string Id,
 53194        string TenantId,
 53195        string TaskId,
 53196        string InvitationId,
 53197        string DispatcherName,
 53198        string? Recipient,
 53199        string ProtectedToken,
 114200        DateTimeOffset ExpiresAt,
 65201        int Attempt,
 217202        DateTimeOffset NotBefore);
 203}
 204
 205/// <summary>
 206/// Resolves a presented guest credential into a task-scoped actor. The actor carries only the permissions a
 207/// guest needs, plus the task and action allowlist the policy layer enforces.
 208/// </summary>
 209public sealed class UserTaskGuestActorResolver(IUserTaskGuestSessionIssuer sessions)
 210{
 211    public const string CredentialScheme = "UserTaskSession";
 212
 213    public async Task<UserTaskActor?> ResolveAsync(string? credential, CancellationToken cancellationToken = default)
 214    {
 215        if (string.IsNullOrWhiteSpace(credential))
 216            return null;
 217        if (await sessions.ResolveAsync(credential, cancellationToken) is not { } session)
 218            return null;
 219
 220        return new UserTaskActor(session.Subject, [], session.Subject.DisplayName)
 221        {
 222            IsManager = false,
 223            Permissions = new HashSet<string>([
 224                new Permission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View).ToString(),
 225                new Permission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Complete).ToString()
 226            ], StringComparer.Ordinal),
 227            GuestTaskId = session.TaskId,
 228            GuestAllowedActions = new HashSet<string>(session.AllowedActions, StringComparer.OrdinalIgnoreCase)
 229        };
 230    }
 231
 232    /// <summary>Extracts the credential from an <c>Authorization: UserTaskSession &lt;token&gt;</c> header value.</summ
 233    public static string? ReadCredential(string? authorizationHeader)
 234    {
 235        if (string.IsNullOrWhiteSpace(authorizationHeader))
 236            return null;
 237        var value = authorizationHeader.Trim();
 238        return value.StartsWith(CredentialScheme + " ", StringComparison.OrdinalIgnoreCase)
 239            ? value[(CredentialScheme.Length + 1)..].Trim()
 240            : null;
 241    }
 242}