Add Workflow State and Transition Permissions
Use this guide to control who can take each Transition of a Workflow, and to grant or deny CRUD permissions by an object's workflow state. You add Workflow Permission, Transition Permission, and State Permission rows, give groups the permissions that those rows name, and then check the results with users from those groups.
Workflow as a Permission Overlay describes how the server evaluates these rows.
Before You Begin
- The model is a Workflow-Enabled Model with a workflow definition: states, an initial state, and transitions with their source states. Manage Workflows and Generate Workflow Migrations gives the steps.
- Your user model includes VUEDAPermissionsMixin. Its
has_permapplies state rules to object checks. - Your model viewsets use ObjectPermissions. It is the default permission class, so a viewset that sets no
permission_classesalready uses it. - Your test users belong to groups that grant the permissions named below. A superuser passes every object check, so it cannot show what a group member sees.
The examples use a myapp.Widget model with states draft, review, and published. Its transitions are submit (draft to review) and approve (review to published). approve needs a custom permission, which you declare in the model's Django: Options.permissions:
class Widget(VuedaModel):
class Meta:
permissions = [("approve_widget", "Can approve widget")]
class Vueda:
class Workflow:
enabled = TrueChoose Where to Create the Rows
- In the browser: add the rows on the workflow, state, and transition edit forms that Manage Workflows and Generate Workflow Migrations describes. Then run
makeworkflowmigrationsto write a Workflow Migration. Give groups their permissions on the Group Management Page, and runmakegroupmigrationsto write a Group Permission Migration. Manage Groups and Generate Group Migrations gives those steps. - In code: create the rows in a test fixture or a Django shell, as the steps below show.
The code in the steps shares this setup:
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from vueda.workflow.models import StatePermission, TransitionPermission, Workflow, WorkflowPermission
from myapp.models import Widget
ct = ContentType.objects.get_for_model(Widget)
workflow = Workflow.objects.get(content_type=ct)
read_widget = Permission.objects.get(content_type=ct, codename="read_widget")
update_widget = Permission.objects.get(content_type=ct, codename="update_widget")
approve_widget = Permission.objects.get(content_type=ct, codename="approve_widget")
read_workflow = Permission.objects.get(content_type__app_label="vueda_workflow", codename="read_workflow")
editors, _ = Group.objects.get_or_create(name="Widget Editors")
reviewers, _ = Group.objects.get_or_create(name="Widget Reviewers")Gate the Workflow
A workflow or transition gate passes only when it has at least one row and the user holds every permission that its rows name. Workflow as a Permission Overlay describes both gates.
Add a
WorkflowPermissionrow for each permission that a user needs to use the workflow at all:pythonWorkflowPermission.objects.create(workflow=workflow, permission=read_widget)Give each group that uses the workflow those permissions, plus
vueda_workflow.read_workflow. Most workflow endpoints checkvueda_workflow.read_workflowfirst.pythoneditors.permissions.add(read_workflow, read_widget, update_widget) reviewers.permissions.add(read_workflow, read_widget, approve_widget)
Gate Each Transition
Add a
TransitionPermissionrow for each permission that a user needs to take the transition:pythonTransitionPermission.objects.create(transition=workflow.transitions.get(code="submit"), permission=update_widget) TransitionPermission.objects.create(transition=workflow.transitions.get(code="approve"), permission=approve_widget)Leave a transition without rows when only your code should take it. No user can take it, and code applies it with
fast_transition, which skips permission checks.
The transition check runs against the object, so state rules apply to the permissions that the transition's rows name.
Grant or Deny Permissions by State
Add a StatePermission row for each permission that a group gains or loses in one state. grant_or_deny=True grants and False denies. The permission's content type selects the model. A grant can give a permission that the group's baseline lacks, and a deny can remove one it has. Workflow as a Permission Overlay describes how rules combine.
This example lets reviewers edit a widget under review, and stops editors from editing a published one:
StatePermission.objects.create(
state=workflow.states.get(code="review"),
permission=update_widget,
group=reviewers,
grant_or_deny=True,
)
StatePermission.objects.create(
state=workflow.states.get(code="published"),
permission=update_widget,
group=editors,
grant_or_deny=False,
)State rules apply only to checks that have an object. A check without an object returns the Baseline Permission.
Check Object Permissions by State
Create one user in each group and one widget in each state. Django caches a user's permissions on the user object, so fetch the user again after you change its groups or their permissions.
Expected results for myapp.update_widget:
| Group | Object state | Baseline Permission | State rule | Expected has_perm |
|---|---|---|---|---|
| Widget Editors | draft | True | none | True |
| Widget Editors | published | True | deny | False |
| Widget Reviewers | review | False | grant | True |
| Widget Reviewers | draft | False | none | False |
assert editor.has_perm("myapp.update_widget", obj=draft_widget) is True
assert editor.has_perm("myapp.update_widget", obj=published_widget) is False
assert reviewer.has_perm("myapp.update_widget", obj=review_widget) is True
assert reviewer.has_perm("myapp.update_widget", obj=draft_widget) is FalseThen send the same updates through the model's API. The reviewer lacks the baseline permission, but the state grant lets the request continue to the object check (Model-Scope Deferral). The reviewer's PATCH to the widget under review succeeds, and the one to the draft widget answers 403. Permissions lists the status code for each refusal.
Check the Transition Endpoints
Sign in as each test user and call the workflow endpoints for myapp/widget:
GET permitted_transitions/lists the transitions whose permissions the user holds. The check has no object, so state rules do not apply. Expectsubmitfor an editor andapprovefor a reviewer. A user without the workflow's permissions gets403.GET object-transitions/{object_id}/lists the transitions that the user can take from the object's current state. Expectsubmitfor an editor on a draft widget, and an empty list for an editor on a widget under review.- The object payload's Valid Transitions field lists the same transitions as
object-transitions. It is empty for a user without the workflow's permissions. GET object-state/{object_id}/and workflow state history need onlyreadon the object. Check that a user withmyapp.read_widgetand no workflow permissions reads both.PATCH execute-transition/{object_id}/with{"transition_code": "submit"}moves a draft widget toreviewfor an editor. The response hasnew_stateandnew_transitions.- Check the refusals. Each of these answers
400with a validation error: a missing or unknowntransition_code, a transition that does not leave the current state, a user without the workflow or transition permissions, and a row that another request has locked. A user who cannot read the object gets403. The bulk form withobject_idsanswers404for an object the user cannot read. When any object fails, the endpoint applies no transition.
Permissions lists these status codes with the others.
Dry-Run a Transition
Send the execute request with the Dry-Run: true header to preview it. The server skips the row lock and runs the same checks. It writes the transition inside the request transaction, returns the projected new_state and new_transitions, and then rolls the transaction back. It calls on_transition with dry_run=True, so an override that works outside the database should check dry_run. Action Contract and Availability describes dry runs for every action.
Confirm Transition Warnings
If the model overrides get_transition_warnings, the first execute request answers 409. The check runs after the permission checks and before any write, in dry runs too. Require Confirmation Before a Write gives the steps.
Check the Client
Sign in to the client as each test user. Transition routes and list view transition buttons follow permitted_transitions, and detail view buttons follow valid_transitions. A user whom permitted_transitions refuses still reaches the model's CRUD routes. A transition route redirects that user with a "Permission Denied" toast. Routing and View Resolution Model describes route admission, and Action Contract and Availability describes where each button comes from.