elsa-core/doc/user-tasks.md
Sipke Schoorstra 9e079b27db
fix(user-tasks): let managers revoke a consumed guest invitation (#7984)
* 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>
2026-08-25 02:38:12 +02:00

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.