Actions & permissions¶
Status: โ v1 implemented ยท ๐ role-based permissions planned
Custom actions ship today via AdminConfig.actions (see Actions today (v1) below).
The fuller per-action permission model โ a codename per (action ร model),
sync_admin_actions, role groups โ is still planned (roadmap P4) and is
documented after the v1 section.
An action (a spell, in Conjure's vocabulary) is something you do to rows beyond CRUD: export, issue a tax invoice, print a receipt, refund, send a push. The design lets you declare actions once, gate them per role, and enforce them on the server.
Actions today (v1)¶
Declare actions on the backend AdminConfig, Django-admin style โ a list of method names plus
the methods themselves:
@register(User)
class UserConfig(AdminConfig):
actions = ["send_welcome", "deactivate"]
def send_welcome(self, request, queryset):
# runs on the targeted rows; return a dict (its "message" is toasted) or None
return {"message": f"{queryset.count()} queued"}
send_welcome.label = "Send welcome" # optional; defaults from the method name
def deactivate(self, request, queryset):
return {"message": f"{queryset.update(is_active=False)} deactivated"}
deactivate.label = "Deactivate"
deactivate.destructive = True # red button + confirm dialog
- The schema exposes each model's
actions(name,label,destructive) and the runtime page renders them in the bulk-action bar โ no per-model frontend code. - Endpoint:
POST /conjure/r/{app.Model}/action/{name}/with{"ids": [...]}(selected rows) or{"all_filtered": true, "params": {...}}("select all N"). Forall_filteredthe server re-applies the filter โ it never trusts a client-sent id list. - Gating:
is_staff+ the model'schangepermission (superusers always pass). Declared actions are not blocked byis_readonly, so a locked-down model can still expose curated actions. Each run is wrapped in a transaction and written toAdminAuditLog.
Planned: role-based action permissions¶
The design below extends v1 with a permission per (action ร model) โ so the same action can
be allowed on Orders but not Subscriptions โ plus sync_admin_actions and role groups.
Four principles¶
- Permission = (action ร model). Django permission codenames are bound to a model's
ContentType, soorder.print_receiptandsubscription.print_receiptare different permissions. "Allowed on Orders but not on Subscriptions" falls out naturally โ no special casing. - Group = role. You don't manage N admins ร M permissions directly. Define ~5โ8 role groups with permission checklists and assign admins to groups. The Django admin Group editor is your model-permission matrix.
- Permissions are data, not migrations. Instead of
Meta.permissions(which needs a migration), async_admin_actionscommand idempotentlyget_or_creates the permission rows. Zero migrations. - The server enforces; the frontend only hides. The action endpoint checks
request.user.has_perm(...). Hiding a button is UX, not security.
The flow¶
graph TD
A["actions.py<br/>ADMIN_ACTIONS = [...]"] -->|sync_admin_actions| B["auth_permission rows<br/>(no migration)"]
B --> C["Django admin Group editor<br/>(define roles via checkboxes)"]
C --> D["Assign admins to groups"]
A --> E["schema sends per-(model,user)<br/>allowed_actions"]
E --> F["Frontend ActionBar<br/>(button + โฏ menu; hides if not allowed)"]
F -->|POST /action/{key}/| G["Action endpoint<br/>has_perm enforced + audit log"]
D --> G
Declaring actions¶
A single source of truth on the backend:
ADMIN_ACTIONS = [
# model, codename, name, kind(client|server), scope(toolbar|bulk)
{"model": "user.User", "codename": "export", "name": "Export users", "kind": "server", "scope": "bulk"},
{"model": "order.Order", "codename": "print_receipt", "name": "Print receipt", "kind": "server", "scope": "bulk"},
{"model": "order.Order", "codename": "issue_tax_invoice", "name": "Issue tax invoice", "kind": "server", "scope": "bulk"},
{"model": "subscription.Subscription", "codename": "print_receipt", "name": "Print receipt", "kind": "server", "scope": "bulk"},
{"model": "notification.Notification", "codename": None, "name": "Send notification", "kind": "server", "scope": "toolbar"},
]
codename: Nonemeans no permission โ available to every staff user (e.g. send notification).- Otherwise the action requires the
{app_label}.{codename}permission. - Same codename on different models โ independent permissions (the Orders-vs-Subscriptions receipt case).
Creating the permissions (no migration)¶
The command walks ADMIN_ACTIONS and get_or_creates a Permission on each action's
model ContentType. Run it on deploy and whenever you add an action. The new permissions
appear automatically as checkboxes under their model in the Django admin Group editor.
The action endpoint¶
The endpoint requires is_staff, then checks the action's permission (has_perm, unless
codename is None); superusers always pass. It dispatches to the action handler,
returns a result with a partial-failure report, and writes an AdminAuditLog entry
(actor, action, targets).
Sensitive actions go through the endpoint
Export of PII and anything destructive must be a server action so the permission is truly enforced โ a client-only action is bypassable by anyone with view access.
The frontend ActionBar¶
The schema sends per-(model, user) allowed_actions:
"actions": [
{ "key": "print_receipt", "label": "Print receipt", "scope": "bulk", "allowed": true },
{ "key": "issue_tax_invoice", "label": "Issue tax invoice", "scope": "bulk", "allowed": false }
]
A generic <ActionBar> uses allowed to filter visibility, renders the 1โ2 common
actions as buttons and the rest under a โฏ more dropdown (hybrid widget), routes
scope: "bulk" actions to the selection bar and scope: "toolbar" to the page header, and
handles confirm dialogs / toasts / refetch. A list page only needs one
<ActionBar model params selectedIds refetch />.
[ 3 selected ] [ Delete ] [ โฏ more โพ ]
โ Print receipt
โ Issue tax invoice
โ Export selected
Worked example¶
The classic test case โ "3 of 10 admins can export users; everyone can send notifications; receipts work on Orders but not Subscriptions" โ is on the Scenarios page.