< Summary

Information
Class: Elsa.UserTasks.Services.UserTaskGuestActorResolver
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs
Line coverage
95%
Covered lines: 21
Uncovered lines: 1
Coverable lines: 22
Total lines: 242
Line coverage: 95.4%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ResolveAsync()75%4493.33%
ReadCredential(...)100%44100%

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>
 116public sealed class InMemoryUserTaskInvitationOutbox(
 117    IDataProtectionProvider dataProtectionProvider,
 118    ISystemClock clock,
 119    IOptions<UserTasksOptions> options) : IUserTaskInvitationOutbox
 120{
 121    private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelive
 122    private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
 123
 124    public Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
 125    {
 126        _entries[delivery.Id] = new Entry(
 127            delivery.Id, delivery.TenantId, delivery.TaskId, delivery.InvitationId, delivery.DispatcherName,
 128            delivery.Recipient, _protector.Protect(JsonSerializer.Serialize(delivery.Token)), delivery.ExpiresAt,
 129            delivery.Attempt, delivery.NotBefore ?? clock.UtcNow);
 130        return Task.CompletedTask;
 131    }
 132
 133    public Task<IReadOnlyCollection<UserTaskInvitationDelivery>> DequeueDueAsync(int maxCount, CancellationToken cancell
 134    {
 135        var now = clock.UtcNow;
 136        foreach (var expired in _entries.Where(x => x.Value.ExpiresAt <= now).ToArray())
 137            _entries.TryRemove(expired.Key, out _);
 138
 139        var due = _entries.Values
 140            .Where(x => x.NotBefore <= now)
 141            .OrderBy(x => x.NotBefore)
 142            .Take(Math.Max(1, maxCount))
 143            .Select(Unprotect)
 144            .Where(x => x != null)
 145            .Select(x => x!)
 146            .ToArray();
 147        return Task.FromResult<IReadOnlyCollection<UserTaskInvitationDelivery>>(due);
 148    }
 149
 150    public Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default)
 151    {
 152        _entries.TryRemove(deliveryId, out _);
 153        return Task.CompletedTask;
 154    }
 155
 156    public Task RescheduleAsync(string deliveryId, DateTimeOffset notBefore, CancellationToken cancellationToken = defau
 157    {
 158        if (!_entries.TryGetValue(deliveryId, out var entry))
 159            return Task.CompletedTask;
 160
 161        var attempt = entry.Attempt + 1;
 162        // Abandon rather than retry forever: an undeliverable secret should expire, and a manager can reissue.
 163        if (attempt > options.Value.InvitationDeliveryRetryDelays.Count)
 164            _entries.TryRemove(deliveryId, out _);
 165        else
 166            _entries[deliveryId] = entry with { Attempt = attempt, NotBefore = notBefore };
 167        return Task.CompletedTask;
 168    }
 169
 170    private UserTaskInvitationDelivery? Unprotect(Entry entry)
 171    {
 172        try
 173        {
 174            var token = JsonSerializer.Deserialize<string>(_protector.Unprotect(entry.ProtectedToken));
 175            return token == null
 176                ? null
 177                : new UserTaskInvitationDelivery(entry.Id, entry.TenantId, entry.TaskId, entry.InvitationId, entry.Dispa
 178                {
 179                    Recipient = entry.Recipient,
 180                    Attempt = entry.Attempt,
 181                    NotBefore = entry.NotBefore
 182                };
 183        }
 184        catch (CryptographicException)
 185        {
 186            // A rotated or unavailable key makes the secret unrecoverable. Drop it instead of surfacing it.
 187            _entries.TryRemove(entry.Id, out _);
 188            return null;
 189        }
 190    }
 191
 192    private sealed record Entry(
 193        string Id,
 194        string TenantId,
 195        string TaskId,
 196        string InvitationId,
 197        string DispatcherName,
 198        string? Recipient,
 199        string ProtectedToken,
 200        DateTimeOffset ExpiresAt,
 201        int Attempt,
 202        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>
 71209public 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    {
 31215        if (string.IsNullOrWhiteSpace(credential))
 0216            return null;
 31217        if (await sessions.ResolveAsync(credential, cancellationToken) is not { } session)
 8218            return null;
 219
 23220        return new UserTaskActor(session.Subject, [], session.Subject.DisplayName)
 23221        {
 23222            IsManager = false,
 23223            Permissions = new HashSet<string>([
 23224                new Permission(UserTasksResourcePermissions.UserTasks, CoreVerbs.View).ToString(),
 23225                new Permission(UserTasksResourcePermissions.UserTasks, UserTaskVerbs.Complete).ToString()
 23226            ], StringComparer.Ordinal),
 23227            GuestTaskId = session.TaskId,
 23228            GuestAllowedActions = new HashSet<string>(session.AllowedActions, StringComparer.OrdinalIgnoreCase)
 23229        };
 31230    }
 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    {
 3235        if (string.IsNullOrWhiteSpace(authorizationHeader))
 1236            return null;
 2237        var value = authorizationHeader.Trim();
 2238        return value.StartsWith(CredentialScheme + " ", StringComparison.OrdinalIgnoreCase)
 2239            ? value[(CredentialScheme.Length + 1)..].Trim()
 2240            : null;
 241    }
 242}