Bump Your Deps

Update Backpex to the latest version:

defp deps do
  [
    {:backpex, "~> 0.21.0"}
  ]
end

v0.21 makes authorization something Backpex enforces rather than something every call site has to remember. Backpex.LiveResource.can?/3 is now evaluated centrally — in Backpex.Resource before any mutation, and directly before every item and resource action runs.

If you never call Backpex.Resource yourself and never wrote a custom item action, the only change you may notice is that a few forged or stale interactions now raise instead of silently doing nothing. If you do, read on: two function signatures changed.

1. Security fix: the item action key no longer comes from the DOM

Security

Before v0.21, submitting an item action modal read the action key from the phx-value-action-key DOM parameter. The can?/3 check ran against that client-supplied key while the server executed the module stored in action_to_confirm — a client could pass the key of an action it is allowed to perform and have a different, unauthorized action executed. This was actively bypassable, not merely easy to forget.

The key is now taken from socket.assigns.action_to_confirm.key, which the view sets when it opens the modal, and phx-value-action-key has been removed from the form. Nothing to do on your side unless you rendered Backpex's form component yourself with a hand-built action-key value.

Client-supplied action keys are also no longer passed through String.to_existing_atom/1. They are matched against the registered item_actions/1 and resource_actions/0 keys, so an unknown key raises Backpex.NoResultsError (404) instead of an ArgumentError.

Removed upload files are validated

Security

Before v0.21, removing an existing file in an upload field accepted any client-supplied file key and passed it on to your remove_uploads/3 callback. A callback that deletes the file from disk (as in the upload field docs) could be made to delete another record's file, or any file reachable by path traversal.

A file key is now only accepted when list_existing_files/1 returns it for the item being edited and the upload field is not readonly. Anything else is ignored, so remove_uploads/3 only ever receives files the item had before the edit. Nothing to do on your side.

2. delete_all/2 → delete_all/4

delete_all needs the assigns to authorize the deletion.

- Backpex.Resource.delete_all(items, socket.assigns.live_resource)
+ Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource)

3. update_all/3 and update_all/4 → update_all/5

update_all also needs the assigns, and event_name moved into the options. The old head had a default argument (event_name \\ "updated"), so it defined both an arity-3 and an arity-4 function. Both are gone:

- Backpex.Resource.update_all(items, updates, MyAppWeb.UserLive)
+ Backpex.Resource.update_all(items, updates, socket.assigns, MyAppWeb.UserLive)

- Backpex.Resource.update_all(items, updates, "deleted", MyAppWeb.UserLive)
+ Backpex.Resource.update_all(items, updates, socket.assigns, MyAppWeb.UserLive, event_name: "deleted")

Watch the argument order

The two old forms fail differently, and neither fails silently:

  • update_all(items, updates, MyAppWeb.UserLive) raises UndefinedFunctionError — there is no update_all/3 any more.
  • update_all(items, updates, "deleted", MyAppWeb.UserLive) has the same arity as the new update_all(items, updates, assigns, live_resource). A guard (is_map(assigns) and not is_struct(assigns)) makes it raise FunctionClauseError instead of quietly authorizing against the event name string.

The same guard also rejects socket where socket.assigns belongs. If you see either error, you missed a call site.

4. Central enforcement and default actions

Every mutation in Backpex.Resource now authorizes before it does anything else — before the changeset is built and before Backpex.Field.before_changeset/6 runs, so your own code never executes for an unauthorized request.

functionauthorizesitem passed to can?/3
insert/6:newnil
update/6:editthe item
update_all/5:editeach item
delete_all/4:deleteeach item

can?/3 must return a boolean. Only true allows; false and nil deny. Any other value raises an ArgumentError, so a policy result like {:error, :unauthorized} can never pass as permission. If you delegate to a policy library, convert its answer, e.g. Policy.authorize(...) == :ok.

Note that insert/6 checks with nil, consistent with every other :new check in Backpex. A clause like def can?(_assigns, :new, nil), do: false is now honored on save, not only when the form is opened.

Reads (list/4, get/4, count/4) are not authorized here. :index and :show are still enforced in the view layer — filtering rows after pagination would corrupt counts and select-all.

What an item action's handle/3 may assume

Backpex calls Backpex.ItemAction.handle/3 only after the gate authorized exactly those items under exactly that action's key. The items you receive are the ones the gate re-read from the data layer, not the snapshot that was rendered. Writing those same items back is the decision the gate already made, so pass authorize?: false instead of paying for a second evaluation of your can?/3:

def handle(socket, items, _data) do
  # already authorized by Backpex before this ran
  Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, authorize?: false)

  {:ok, socket}
end

The guarantee covers nothing else. Writes to other items, or to another resource, keep the default gate.

Overriding the action: :authorization_action

When an action writes items the gate did not cover, :authorization_action picks the key to check instead of the :new / :edit / :delete default:

Backpex.Resource.update_all(other_items, updates, socket.assigns, socket.assigns.live_resource,
  authorization_action: socket.assigns.item_action_key
)

assigns.item_action_key is new in v0.21. Backpex sets it immediately before calling Backpex.ItemAction.handle/3 and clears it again afterwards, so it is meaningful exactly for the duration of one dispatch and the action does not need to know which key it was registered under.

The option must be a non-nil atom; anything else raises ArgumentError rather than reaching a permissive catch-all can?/3 clause.

Skipping the check: authorize?: false

System and cascade writes are not user-initiated actions on the resource they touch. Skip the check explicitly:

# nullify the foreign key on another resource
Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, MyAppWeb.PostLive,
  event_name: "updated",
  authorize?: false
)

The option is deliberately explicit and greppable. Reach for it only when the write is already covered by the gate, or when it really is not the user's action on that resource.

5. Item actions are strict now

Previously, an item action silently filtered unauthorized items out of the selection and then ran handle/3 with what was left — including an empty list, which usually reported success. That is gone.

  • A selection containing a single unauthorized item raises Backpex.ForbiddenError. Nothing runs.
  • A stale or forged item id raises Backpex.NoResultsError. nil never reaches your can?/3.
  • An unknown action key raises Backpex.NoResultsError.
  • handle/3 receives the full list of selected items, and is not called at all for an empty selection.
  • The bulk action button in the toolbar is disabled when the selection is empty or contains any unauthorized item, so the UI never offers a click that would raise. Its title says why.
  • A row that is authorized for none of the bulk actions can no longer be selected: its checkbox is disabled, and "select all" skips it. Without this a user could build a selection whose every action is disabled, with no way to tell which row caused it.

Three consequences worth knowing:

  • A raised gate is not an error page. Both exceptions carry a plug_status, but LiveView only maps that to an HTTP status while a view mounts. From an event handler on a connected socket the LiveView process crashes and the client reloads the page — no 403 page, no flash. That is intended: the gate exists so the write does not happen, not to explain itself. The preflight checks are what keep a user from reaching a gate through the UI at all.

  • Double-clicking a delete button after the item is gone now raises Backpex.NoResultsError — the LiveView crashes and reconnects instead of flashing "0 items deleted". This is a deliberate trade-off for not leaking whether an id exists.

  • The execution gate checks re-read records, not the rendered ones. Right before it authorizes, Backpex re-reads every selected item by its primary key through the adapter, checks can?/3 against those records and passes them to handle/3. A row another actor changed while the confirmation modal was open is judged by its current values; a row that was deleted, or that has left the resource's item_query/3 scope, comes back as nil and raises Backpex.NoResultsError. The check when the modal opens still uses the rendered items — it only decides whether the dialog appears.

    This is not a lock. A window remains between the re-read and whatever your handle/3 writes; Backpex opens no transaction and takes no row locks there. An action that needs strict atomicity has to re-read and lock inside its own handle/3 (Ecto.Repo.transaction/2 plus a lock: "FOR UPDATE" query).

6. handle_item_action/5 behavior change

Backpex.ItemAction.handle_item_action/5 is public. It no longer filters items; it re-reads them, authorizes them and raises. If you call it yourself, expect Backpex.ForbiddenError / Backpex.NoResultsError where you previously got a shorter list, and expect Backpex.ItemAction.handle/3 to receive the re-read records rather than the list you passed in. Backpex.ItemAction.authorize_fresh!/3 is that re-read-then-authorize step on its own, and Backpex.ItemAction.dispatch/5 remains the unguarded "just run it" primitive.

7. Do not let a rescue swallow the gate

This applies to Backpex.Resource calls that are still gated — the ones you did not pass authorize?: false. A broad rescue around such a call now catches Backpex.ForbiddenError and Backpex.NoResultsError as well, turning a refused write into an ordinary flash message. That is worse than the crash it replaces: the request looks handled, and the failure is filed under "something went wrong" instead of "you may not do this". Reraise them:

def handle(socket, items, _data) do
  # ...
rescue
  error in [Backpex.ForbiddenError, Backpex.NoResultsError] ->
    reraise error, __STACKTRACE__

  error ->
    # your existing error handling
end

8. Readonly fields

readonly is now a global field option. Existing configurations keep working, but check these changes when upgrading:

  • Disabled checkboxes and toggles no longer submit a hidden false value. Custom form handlers must preserve existing values when these parameters are absent.
  • Nested fields inherit a readonly parent; a child's readonly: false cannot override it.
  • Review custom CSS and DOM selectors for the updated readonly styling and non-interactive dropdown markup.
  • Code inspecting field schemas must include Backpex.Field.default_config_schema/0 to find the global readonly option.

See the Readonly guide for supported fields and behavior, and Readonly for custom fields for custom implementations.

Checklist for custom actions

  • [ ] Every delete_all/2 call updated to delete_all/4.
  • [ ] Every update_all/3 and update_all/4 call updated to update_all/5,
    with `event_name:` moved into the options. The arity-3 form raises
    `UndefinedFunctionError`, the arity-4 form `FunctionClauseError`.
  • [ ] Item actions that write back the items they were handed pass
    `authorize?: false` — the gate already covered them.
  • [ ] Writes to other items pass authorization_action: when the default
    `:new` / `:edit` / `:delete` is not the right key.
  • [ ] Cascade or system writes pass authorize?: false.
  • [ ] Broad rescue clauses around a still-gated Backpex.Resource call
    reraise `Backpex.ForbiddenError` and `Backpex.NoResultsError`.
  • [ ] handle/3 implementations cope with receiving the full selection (they are
    no longer handed a pre-filtered list, and are never called with `[]`).