elsa-core/specs/013-user-tasks/quickstart.md
Sipke Schoorstra 292e4bd3ea
feat(user-tasks)!: migrate endpoints to structured permissions (#7999)
Re-authors the nine User Tasks permissions as verbs on the user-tasks and
user-tasks/participants resources with a descriptor provider, replacing the
legacy verb:resource strings (UserTasksPermissions is removed along with the
other legacy constant classes). All 17 endpoints declare access through
RequirePermission, and UserTaskActor.HasPermission matches through
PermissionMatcher instead of string equality, so pattern grants reach these
endpoints for the first time. manage:user-tasks becomes user-tasks:supervise
to reflect that it grants oversight, not an aggregate. The migration guide
and contract specs carry the full mapping.

BREAKING CHANGE: legacy user-tasks permission strings no longer authorize
anything. Rewrite grants using the mapping table in
doc/migrations/authorization-model.md.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 12:06:52 +02:00

11 KiB

Quickstart: Elsa User Tasks

Status: Approved
Last reviewed: 2026-08-17

This walkthrough creates a workflow-bound invoice approval, executes it, lists the candidate task, claims it as an external user, and completes it with a typed action and form payload. The examples use the planned Elsa.UserTasks REST contract and do not require Elsa.Identity.

Prerequisites

  • An Elsa Server with workflow management, workflow runtime, and the User Tasks module enabled.
  • A host identity provider that issues a bearer token with a stable subject and optional group claims.
  • The user-tasks:view, user-tasks:claim, and user-tasks:complete permissions for the worker token. A manager token additionally needs user-tasks:assign and user-tasks:supervise.
  • An installed form provider if the task uses formReference.

The Core module defaults to an in-memory repository for development and tests. A durable host adds the User Tasks EF Core persistence package and its provider-specific shell feature, following the same package split used by Elsa.Secrets:

services.AddElsa(elsa =>
{
    elsa.UseUserTasks();
});

// Enable the provider-specific User Tasks persistence feature in the host.
// The feature registers the User Task repository, DbContext, mappings, and migrations.
// Use the SQLite, SQL Server, PostgreSQL, MySQL, or Oracle package selected by the host.

Do not add an Elsa.Identity reference merely to run this walkthrough. The default claims resolver reads the configured subject/provider/group claims; a host may replace it with IUserTaskIdentityResolver and IUserTaskAccessPolicy.

Create the workflow

In Elsa Studio, create a workflow definition named invoice-review with a sequence containing one UserTask activity:

new UserTask
{
    Id = "ReviewInvoice",
    Title = "Review invoice",
    Summary = "Approve or reject invoice INV-1042.",
    Instructions = "Check the supplier, amount, and purchase order before deciding.",
    Reference = "INV-1042",
    TaskType = "invoice-approval",
    CandidateGroups =
    [
        new ParticipantReference(
            TenantId: "acme",
            Provider: "acme-directory",
            Type: UserTaskParticipantType.Group,
            Id: "accounts-payable")
    ],
    Priority = 75,
    DueAt = "= now() + duration(\"P2D\")",
    FormReference = new UserTaskFormReference
    {
        ProviderName = "acme-forms",
        Key = "invoice-review",
        Binding = "version:3"
    },
    Actions =
    [
        new UserTaskActionDefinition { Key = "approved", Label = "Approve" },
        new UserTaskActionDefinition { Key = "rejected", Label = "Reject" }
    ],
    TaskData = new
    {
        invoiceNumber = "INV-1042",
        amount = 1250.00,
        currency = "EUR",
        supplier = "Northwind Supplies"
    }
};

The C# above describes the activity shape used by the workflow builder. Studio serializes the same values into the workflow definition. CandidateGroups stores an opaque external reference; the string accounts-payable is not an Elsa group ID. The activity blocks on its User Task bookmark and exposes the selected action as the workflow outcome after completion.

If a form provider is not installed, omit FormReference and complete with an action only. Without a form, arbitrary business data is not accepted by the Core completion endpoint.

Execute the workflow

After publishing the definition, execute it through the existing workflow runtime endpoint. Set DEFINITION_ID to the published definition ID returned by the workflow-definition API:

export ELSA_BASE_URL="https://localhost:5001"
export DEFINITION_ID="invoice-review"
export WORKER_TOKEN="<worker-access-token>"

curl --fail-with-body -i \
  -X POST "$ELSA_BASE_URL/workflow-definitions/$DEFINITION_ID/execute" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "correlationId": "order-1042",
    "input": {
      "invoiceNumber": "INV-1042"
    }
  }'

The response includes the normal workflow state and the x-elsa-workflow-instance-id response header. Save that value for the task detail and workflow deep link.

List the candidate queue

The accounts-payable worker supplies a token whose external group claims include acme-directory/accounts-payable. The User Tasks API evaluates the host identity policy and returns summary-safe rows:

curl --fail-with-body \
  "$ELSA_BASE_URL/user-tasks?scope=available&taskType=invoice-approval&limit=20&includeTotalCount=true" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  -H "Accept: application/json"

An available row has this shape (the exact response may include links and tenant metadata):

{
  "items": [
    {
      "id": "01JUSER-TASK-1042",
      "title": "Review invoice",
      "summary": "Approve or reject invoice INV-1042.",
      "reference": "INV-1042",
      "taskType": "invoice-approval",
      "status": "Available",
      "assignee": null,
      "priority": 75,
      "dueAt": "2026-08-19T10:30:00Z",
      "isOverdue": false,
      "allowedActions": ["claim"],
      "dataAccess": "summary",
      "revision": 1
    }
  ],
  "totalCount": 1
}

The row does not expose Instructions, TaskData, or the form payload. A worker can search by reference, status, priority, due-date range, workflow definition/instance, and stable cursor/sort parameters. Studio uses the same endpoint for its Available tab.

Claim the task

Claiming is a separate, concurrency-safe operation. The expectedRevision prevents a stale list row from claiming a task that another worker already took:

export TASK_ID="01JUSER-TASK-1042"

curl --fail-with-body \
  -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/claim" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expectedRevision":1}'

A successful claim changes the task to Assigned, sets the assignee to the external subject from the token, increments revision, and returns allowedActions containing release and complete.

Read protected details

After claiming, the worker can fetch the full detail. The same request made with a different candidate token returns only the safe projection or 404, depending on the host access policy:

curl --fail-with-body \
  "$ELSA_BASE_URL/user-tasks/$TASK_ID" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  -H "Accept: application/json"

The assigned response includes the pinned form reference, protected instructions, task data, workflow definition/instance links, action definitions, revision, and a safe lifecycle timeline. It still omits secrets that the host form provider does not authorize.

Complete with a form action

The form provider validates and normalizes data. The actionKey must match a configured action; it is also emitted as the User Task activity outcome. The operationId makes a network retry idempotent:

export OPERATION_ID="6d1cf9d1-22e4-4f4d-a13e-0f9a50b754d8"

curl --fail-with-body -i \
  -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/complete" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "expectedRevision": 2,
    "operationId": "'"$OPERATION_ID"'",
    "actionKey": "approved",
    "data": {
      "purchaseOrder": "PO-1042",
      "approvedAmount": 1250.00
    }
  }'

The endpoint returns 202 Accepted while the task is Completing. Poll the task resource until Completed:

until curl --fail-with-body -s \
  "$ELSA_BASE_URL/user-tasks/$TASK_ID" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  | rg -q '"status"[[:space:]]*:[[:space:]]*"Completed"'; do
  sleep 1
done

When the bookmark resumes, the workflow receives UserTaskResult with actionKey = "approved", the provider-normalized data, the external completing participant, and the completion timestamp. A workflow branch can use the approved or rejected outcome to continue execution.

Submitting the same operationId and payload again returns the original completion result or an equivalent idempotent response. Reusing it with a different action or data returns 409 Conflict.

Release or reassign

If the worker cannot finish, release clears the assignee and returns the task to the candidate queue:

curl --fail-with-body \
  -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/release" \
  -H "Authorization: Bearer $WORKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expectedRevision":2}'

A manager can assign the task to a specific external user. Managers cannot complete on another user's behalf; they must assign it to an accountable user first:

export MANAGER_TOKEN="<manager-access-token>"

curl --fail-with-body \
  -X POST "$ELSA_BASE_URL/user-tasks/$TASK_ID/assign" \
  -H "Authorization: Bearer $MANAGER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "expectedRevision": 3,
    "assignee": {
      "provider": "acme-directory",
      "type": "user",
      "id": "u-2048"
    },
    "reason": "Accounts-payable coverage for the month-end queue."
  }'

Inspect the manager queue and audit history

Managers with user-tasks:supervise can query all task scopes and see the safe event timeline:

curl --fail-with-body \
  "$ELSA_BASE_URL/user-tasks?scope=all&status=Assigned&priorityFrom=50&limit=50" \
  -H "Authorization: Bearer $MANAGER_TOKEN" \
  -H "Accept: application/json"

curl --fail-with-body \
  "$ELSA_BASE_URL/user-tasks/$TASK_ID/events" \
  -H "Authorization: Bearer $MANAGER_TOKEN" \
  -H "Accept: application/json"

Events include created, claimed, released, assigned, reassigned, completion requested, completed, and canceled transitions with timestamps, external participant references, revisions, and safe reasons. They do not include protected task or form payloads.

Validate the integration

Run these scenarios against an in-memory host first, then repeat with the selected EF Core provider:

  1. Two candidate workers claim the same task; one succeeds and the other receives 409.
  2. A candidate can list a summary but cannot fetch protected data before claiming.
  3. A claimed worker can release and another candidate can claim the task.
  4. A completion retry with the same operationId does not resume the bookmark twice.
  5. A stale expectedRevision cannot complete or reassign a task.
  6. A task with no assignment is manager-only and produces a design/runtime warning.
  7. A missing form provider produces a blocking health issue and never renders arbitrary HTML.
  8. The workflow receives the configured action outcome and typed result after Completing reaches Completed.
  9. A canceled workflow removes the bookmark and records the User Task as canceled.
  10. No request or persistence path requires an Elsa.Identity user or group.

The implementation should turn each scenario into a Core integration or component test and keep this document synchronized with the REST and runtime contracts.