Bump Your Deps
Update Backpex to the latest version:
defp deps do
[
{:backpex, "~> 0.21.0"}
]
endv0.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)raisesUndefinedFunctionError— there is noupdate_all/3any more.update_all(items, updates, "deleted", MyAppWeb.UserLive)has the same arity as the newupdate_all(items, updates, assigns, live_resource). A guard (is_map(assigns) and not is_struct(assigns)) makes it raiseFunctionClauseErrorinstead 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.
| function | authorizes | item passed to can?/3 |
|---|---|---|
insert/6 | :new | nil |
update/6 | :edit | the item |
update_all/5 | :edit | each item |
delete_all/4 | :delete | each 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}
endThe 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.nilnever reaches yourcan?/3. - An unknown action key raises
Backpex.NoResultsError. handle/3receives 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
titlesays 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?/3against those records and passes them tohandle/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'sitem_query/3scope, comes back asniland raisesBackpex.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/3writes; Backpex opens no transaction and takes no row locks there. An action that needs strict atomicity has to re-read and lock inside its ownhandle/3(Ecto.Repo.transaction/2plus alock: "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
end8. 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
falsevalue. Custom form handlers must preserve existing values when these parameters are absent. - Nested fields inherit a readonly parent; a child's
readonly: falsecannot 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/0to find the globalreadonlyoption.
See the Readonly guide for supported fields and behavior, and Readonly for custom fields for custom implementations.
Checklist for custom actions
- [ ] Every
delete_all/2call updated todelete_all/4. - [ ] Every
update_all/3andupdate_all/4call updated toupdate_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
rescueclauses around a still-gatedBackpex.Resourcecallreraise `Backpex.ForbiddenError` and `Backpex.NoResultsError`. - [ ]
handle/3implementations cope with receiving the full selection (they areno longer handed a pre-filtered list, and are never called with `[]`).