* fix(user-tasks): let managers revoke a consumed guest invitation Verification marks the winning invitation Consumed, which is what issues the guest session — but RevokeAsync rejected Consumed and never touched sessions at all. A manager therefore could not withdraw a live guest credential: it stayed authorized until its TTL elapsed or the task closed. The invitations contract specifies a revocable, task-scoped session, so this was a real gap. RevokeAsync now accepts a consumed invitation, rejecting only the already terminal Revoked and Expired states, and revokes the sessions that invitation issued. Revocation is scoped to one invitation rather than the whole task, so other guests keep working: UserTaskGuestSession carries its InvitationId and IUserTaskGuestSessionIssuer gains RevokeForInvitationAsync, implemented for both the in-memory and EF Core stores. Reassignment already cut a guest off, because the policy requires the guest to still be the assignee. That remains the recovery path for abandoned guest work; this restores the documented direct revocation alongside it. Adds three tests. The first fails against the previous behavior. Reported by Greptile on #7955. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(user-tasks): make guest-session revocation fail closed and retryable Greptile review of the previous commit found three real problems with it. Revocation committed the invitation as Revoked before revoking its sessions, so a session-store failure left a live credential behind a guard that rejected the retry. Sessions are now swept before the terminal state is committed: a failure commits nothing, leaves the invitation revocable, and a retry repairs it. A retry against an already-revoked invitation is idempotently successful and re-runs the sweep, so a caller repairing a partial failure is never told no. Verification could also hand back a credential that outlived a concurrent revoke: the manager's sweep ran before the session reached the store and found nothing. VerifyAsync now re-reads the committed invitation after issuing and withdraws the credential unless it is still the consumed one it verified. Invitation-scoped revocation queried an unindexed column, so every revoke scanned a growing tenant partition of retained session rows. Adds the (TenantId, InvitationId) index to the EF model and migration, and advertises the same index from the VNext schema provider. Adds three tests covering the injected store failure, the idempotent retry, and the revoke-during-verify race. RevokingAnAlreadyRevokedInvitationIsRefused asserted the behavior this commit deliberately changes, so it is repurposed to cover the refusal that remains: an unknown invitation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(user-tasks): sweep guest sessions on both sides of the revoke commit Moving the sweep before the commit closed the fail-open failure path but opened its mirror: a concurrent verification can issue a session after the sweep, still read Consumed at its settled-state check because the revoke has not committed yet, and hand back a credential that outlives a successful revoke. Revocation now sweeps after the commit as well. Anything issued in that window is caught by the second sweep, and any verification that issues after the commit sees the revoked state at its own settled-state check and withdraws its own credential. The first sweep still runs before the commit, so a session-store failure commits nothing and stays retryable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
6 KiB
User Tasks
User Tasks add durable, workflow-bound human work to Elsa. A workflow suspends at the UserTask activity, authorized workers discover and act on the task through /user-tasks, and the workflow resumes with a typed action and validated form result.
Identity integration
The module does not require Elsa.Identity. It stores opaque, tenant-scoped participant references:
{
"tenantId": "acme",
"provider": "entra",
"type": "User",
"id": "external-subject-id"
}
The default adapter maps namespaced claims from ClaimsPrincipal. Embedded hosts can replace IUserTaskIdentityResolver, IUserTaskAccessPolicy, and IUserTaskParticipantDirectory to integrate their own authentication, authorization, users, and groups. Directory lookup enriches display and snapshot groups; it is never required for an exact live claim match.
Every operation requires both its module permission and a relationship to the task. Candidate workers can read safe queue metadata and claim. Only the assignee and tenant-scoped managers can read protected instructions, task data, and form content. Releasing a task revokes that access immediately.
Lifecycle
Direct assignments start as Assigned; candidate or invitation work starts as Available; a task with no route to a worker starts as manager-only Unassigned. Completion, timeout, and cancellation first enter a transitional state while the workflow bookmark is resumed, then finalize as Completed, TimedOut, or Cancelled.
Claims and terminal actions use optimistic concurrency. Completion and cancellation require a client operation ID, making same-payload retries idempotent while rejecting divergent reuse. A post-commit bookmark projector and paged reconciler repair interrupted projection and resume delivery without duplicating tasks.
Forms and actions
Action keys are stable literals; their labels may be expressions. Timeout and Cancelled are reserved. An optional IUserTaskFormProvider resolves and pins a provider-neutral form reference when the task activates, then validates and normalizes the completion payload. An unresolved form creates a blocking manager health issue. Tasks without a form accept an action only.
Protected task, form, and completion payloads are limited to 256 KiB by default. Put files in an external object/document provider and submit references rather than file bytes.
Persistence
The Core feature includes an in-memory repository for development and tests. Production hosts can select the EF Core User Tasks package for SQLite, SQL Server, PostgreSQL, MySQL, or Oracle, or use the VNext persistence adapter. The provider-neutral API and activity contract do not change with the store.
Terminal tasks are retained indefinitely by default. Configurable cleanup may purge terminal aggregates and their protected data, but never open tasks. Audit events remain append-only and contain safe metadata only.
HTTP surface
Clients read GET /user-tasks/capabilities before rendering navigation, then work through /user-tasks for the queue and /user-tasks/{id} for a single task. Every command carries an operationId that the client mints once per submission and reuses on retry, so a double click or a network retry is recognised as the same command rather than accepted as a second one.
Terminal commands (complete, cancel, retry resolution) answer 202: the workflow resumes out of band and the client observes the final state by requery or invalidation. Conflicts answer 409, semantic input errors 422, and anonymous invitation traffic is throttled with 429. Every failure body is { code, message } with display-safe copy — never exception text or payload fragments.
Masked form fields never travel with the task detail. A client that needs one calls POST /user-tasks/{id}/reveal, which requires protected access, requires the form provider to have marked the field revealable, and writes the disclosure to the audit trail.
Guest invitations
Guest invitations are task-scoped and do not create Elsa users. Core hashes the one-time token, retains retry material only in a Data Protection-encrypted transient outbox, and delegates delivery and challenge verification to host services. The first successful verification claims the task, revokes sibling invitations, and issues a bounded guest session. Anonymous errors are generic and rate-limited; guests cannot release or reassign work.
The transient delivery outbox encrypts pending secrets with ASP.NET Core Data Protection, so the module needs an IDataProtectionProvider. Web hosts register one by default; a non-web host that enables invitations must call AddDataProtection() itself.
Revoking an invitation revokes the session it issued, including after the invitation has been consumed — that is the case where a live guest credential exists and a manager needs to withdraw it. Revocation is scoped to the one invitation, so other guests on the same task keep working.
A guest presents its session as Authorization: UserTaskSession <credential> against /user-task-sessions/current and /user-task-sessions/current/complete. The session identifies the task, so no task ID appears in the route and a guest can never address a task other than the one its invitation was issued for. Completion is intersected with the action allowlist pinned at issuance, and every session for a task is revoked as soon as that task closes.
Studio and custom applications
Elsa Studio provides Workflows → User Tasks with Assigned to me, Available, History, and manager-only All and Needs Attention views. Studio is a reference workbench, not a required runtime dependency. Custom applications use the same REST APIs and server-computed capability projections. Realtime messages are invalidations only; clients always requery authorized data.
See the feature specification, REST contract, and quickstart for the complete design and examples.