From 5fef4c365abf68aea254f8b3efe0c211960f1460 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:09:20 +0200 Subject: [PATCH 01/29] Add Backpex.Authorization module Centralize all can?/3 checks behind a single module with preflight (can?/can_all?) and gate (authorize!/authorize_all!) functions, and route the existing raise sites in the index, show and form views through it. --- lib/backpex/authorization.ex | 85 +++++++++++++++++++++ lib/backpex/live_resource.ex | 20 +++++ lib/backpex/live_resource/form.ex | 5 +- lib/backpex/live_resource/index.ex | 5 +- lib/backpex/live_resource/show.ex | 3 +- test/backpex/authorization_test.exs | 112 ++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 lib/backpex/authorization.ex create mode 100644 test/backpex/authorization_test.exs diff --git a/lib/backpex/authorization.ex b/lib/backpex/authorization.ex new file mode 100644 index 000000000..3f373a679 --- /dev/null +++ b/lib/backpex/authorization.ex @@ -0,0 +1,85 @@ +defmodule Backpex.Authorization do + @moduledoc """ + Central entry point for all Backpex authorization checks. + + Every check ultimately calls `c:Backpex.LiveResource.can?/3` on the given LiveResource. Routing all + checks through this module gives Backpex a single place to enforce authorization — and a single + place to extend it later (for example with a dedicated authorizer behaviour). + + There are two flavours of functions: + + * **Preflight** (`can?/4`, `can_all?/4`) — answer a question. Use these in the UI to decide whether + to render or disable a control. + * **Gates** (`authorize!/4`, `authorize_all!/4`) — enforce the answer. Use these right before + something actually happens. They raise instead of returning `false`. + + ## Failure semantics + + * unauthorized → `Backpex.ForbiddenError` (403) + * `nil` item in `authorize_all!/4` (a stale or forged item id) → `Backpex.NoResultsError` (404) + + A `nil` item never reaches `c:Backpex.LiveResource.can?/3` through `authorize_all!/4`. This keeps + user implementations free of `nil` clauses they never asked for, and it does not leak whether an + id exists. + + ## Strict semantics + + `can_all?/4` and `authorize_all!/4` are strict: a single unauthorized item makes the whole call + fail. Backpex does not silently drop unauthorized items from a selection. + + Note that `Enum.all?/2` returns `true` for an empty list, so an empty selection passes vacuously. + Callers that need "empty means not allowed" (a disabled bulk action button, for example) must + handle the empty case themselves. + """ + + @doc """ + Returns whether `action` may be performed on `item` for the given LiveResource. + + Pass `nil` as `item` for actions that are not bound to a specific item (`:index`, `:new`, resource + actions). + """ + @spec can?(module(), map(), atom(), map() | nil) :: boolean() + def can?(live_resource, assigns, action, item) when is_atom(live_resource) and is_map(assigns) and is_atom(action) do + live_resource.can?(assigns, action, item) + end + + @doc """ + Returns whether `action` may be performed on **every** item in `items`. + + Returns `true` for an empty list. + """ + @spec can_all?(module(), map(), atom(), list()) :: boolean() + def can_all?(live_resource, assigns, action, items) when is_list(items) do + Enum.all?(items, &can?(live_resource, assigns, action, &1)) + end + + @doc """ + Ensures `action` may be performed on `item`, raising `Backpex.ForbiddenError` otherwise. + + Returns `:ok`. + """ + @spec authorize!(module(), map(), atom(), map() | nil) :: :ok + def authorize!(live_resource, assigns, action, item) do + if can?(live_resource, assigns, action, item) do + :ok + else + raise Backpex.ForbiddenError + end + end + + @doc """ + Ensures `action` may be performed on **every** item in `items`. + + Raises `Backpex.ForbiddenError` when any item is not authorized and `Backpex.NoResultsError` when + the list contains `nil` (a stale or forged item id). + + Returns `:ok`. An empty list passes. + """ + @spec authorize_all!(module(), map(), atom(), list()) :: :ok + def authorize_all!(live_resource, assigns, action, items) when is_list(items) do + Enum.each(items, fn + nil -> raise Backpex.NoResultsError + item -> authorize!(live_resource, assigns, action, item) + end) + end +end diff --git a/lib/backpex/live_resource.ex b/lib/backpex/live_resource.ex index ce8e45414..e68d86f7b 100644 --- a/lib/backpex/live_resource.ex +++ b/lib/backpex/live_resource.ex @@ -171,6 +171,26 @@ defmodule Backpex.LiveResource do @doc """ The function that can be used to restrict access to certain actions. It will be called before performing an action and aborts when the function returns `false`. + + ## Enforcement + + Backpex enforces this callback centrally through `Backpex.Authorization`. It is evaluated both as a + preflight check (to hide or disable controls) and as a hard gate immediately before an action runs: + + * `Backpex.Resource.insert/6` authorizes `:new` with a `nil` item. + * `Backpex.Resource.update/6` authorizes `:edit` with the item. + * `Backpex.Resource.delete_all/4` authorizes `:delete` per item. + * `Backpex.Resource.update_all/5` authorizes `:edit` per item. + * Item actions authorize their action key per item, resource actions authorize their key with a `nil` item. + + The default action can be overridden per call with the `:authorization_action` option, and skipped + entirely with `authorize?: false` for system writes. + + Enforcement is strict: a single unauthorized item in a selection raises `Backpex.ForbiddenError` + instead of silently dropping that item. + + Read operations (`:index`, `:show`) are enforced in the view layer only. `Backpex.Resource.list/4`, + `Backpex.Resource.get/4` and `Backpex.Resource.count/4` do not call this callback. """ @callback can?(assigns :: map(), action :: atom(), item :: map() | nil) :: boolean() diff --git a/lib/backpex/live_resource/form.ex b/lib/backpex/live_resource/form.ex index 7b2465f0a..260b90e15 100644 --- a/lib/backpex/live_resource/form.ex +++ b/lib/backpex/live_resource/form.ex @@ -4,6 +4,7 @@ defmodule Backpex.LiveResource.Form do import Phoenix.Component + alias Backpex.Authorization alias Backpex.LiveResource alias Backpex.Resource @@ -94,13 +95,13 @@ defmodule Backpex.LiveResource.Form do end defp can?(socket, live_resource, :new = live_action) do - if not live_resource.can?(socket.assigns, live_action, nil), do: raise(Backpex.ForbiddenError) + Authorization.authorize!(live_resource, socket.assigns, live_action, nil) socket end defp can?(socket, live_resource, :edit = live_action) do - if not live_resource.can?(socket.assigns, live_action, socket.assigns.item), do: raise(Backpex.ForbiddenError) + Authorization.authorize!(live_resource, socket.assigns, live_action, socket.assigns.item) socket end diff --git a/lib/backpex/live_resource/index.ex b/lib/backpex/live_resource/index.ex index f44c4597e..1b149ef93 100644 --- a/lib/backpex/live_resource/index.ex +++ b/lib/backpex/live_resource/index.ex @@ -5,6 +5,7 @@ defmodule Backpex.LiveResource.Index do import Phoenix.Component alias Backpex.Adapters.Ecto, as: EctoAdapter + alias Backpex.Authorization alias Backpex.FilterValidation alias Backpex.LiveResource alias Backpex.PaginationValidation @@ -496,7 +497,7 @@ defmodule Backpex.LiveResource.Index do action = live_resource.resource_actions()[id] - if not live_resource.can?(socket.assigns, id, nil), do: raise(Backpex.ForbiddenError) + Authorization.authorize!(live_resource, socket.assigns, id, nil) changeset_function = fn item, changes, metadata -> action.module.changeset(item, changes, metadata) end item = action.module.base_schema(socket.assigns) @@ -514,7 +515,7 @@ defmodule Backpex.LiveResource.Index do %{live_resource: live_resource, params: params, fields: fields} = socket.assigns persisted = socket.assigns[:backpex_persisted_index_state] || %{order: nil, filters: nil} - if not live_resource.can?(socket.assigns, :index, nil), do: raise(Backpex.ForbiddenError) + Authorization.authorize!(live_resource, socket.assigns, :index, nil) per_page_options = live_resource.config(:per_page_options) per_page_default = live_resource.config(:per_page_default) diff --git a/lib/backpex/live_resource/show.ex b/lib/backpex/live_resource/show.ex index 0edb223d8..3100c002e 100644 --- a/lib/backpex/live_resource/show.ex +++ b/lib/backpex/live_resource/show.ex @@ -4,6 +4,7 @@ defmodule Backpex.LiveResource.Show do import Phoenix.Component + alias Backpex.Authorization alias Backpex.Resource alias Backpex.Router @@ -61,7 +62,7 @@ defmodule Backpex.LiveResource.Show do primary_value = URI.decode(backpex_id) item = Resource.get!(primary_value, fields, socket.assigns, live_resource) - if not live_resource.can?(socket.assigns, :show, item), do: raise(Backpex.ForbiddenError) + Authorization.authorize!(live_resource, socket.assigns, :show, item) socket |> assign(:item, item) diff --git a/test/backpex/authorization_test.exs b/test/backpex/authorization_test.exs new file mode 100644 index 000000000..b42889e10 --- /dev/null +++ b/test/backpex/authorization_test.exs @@ -0,0 +1,112 @@ +defmodule Backpex.AuthorizationTest do + use ExUnit.Case, async: true + + alias Backpex.Authorization + + defmodule AllowAll do + @moduledoc false + def can?(_assigns, _action, _item), do: true + end + + defmodule DenyAll do + @moduledoc false + def can?(_assigns, _action, _item), do: false + end + + defmodule KeyAware do + @moduledoc false + def can?(_assigns, :delete, %{role: :admin} = _item), do: false + def can?(_assigns, :delete, _item), do: true + def can?(_assigns, :new, nil), do: false + def can?(_assigns, _action, _item), do: true + end + + defmodule Recorder do + @moduledoc false + def can?(assigns, action, item) do + send(assigns.test_pid, {:can?, action, item}) + true + end + end + + @assigns %{live_resource: AllowAll} + + describe "can?/4" do + test "delegates to the live resource" do + assert Authorization.can?(AllowAll, @assigns, :new, nil) + refute Authorization.can?(DenyAll, @assigns, :new, nil) + end + + test "passes action and item through untouched" do + item = %{id: 1} + + assert Authorization.can?(Recorder, %{test_pid: self()}, :edit, item) + assert_received {:can?, :edit, ^item} + end + + test "supports a nil item" do + refute Authorization.can?(KeyAware, @assigns, :new, nil) + assert Authorization.can?(KeyAware, @assigns, :edit, nil) + end + end + + describe "can_all?/4" do + test "returns true when every item is authorized" do + assert Authorization.can_all?(KeyAware, @assigns, :delete, [%{role: :user}, %{role: :user}]) + end + + test "returns false when a single item is unauthorized" do + refute Authorization.can_all?(KeyAware, @assigns, :delete, [%{role: :user}, %{role: :admin}]) + end + + test "returns true for an empty list" do + assert Authorization.can_all?(DenyAll, @assigns, :delete, []) + end + end + + describe "authorize!/4" do + test "returns :ok when authorized" do + assert :ok = Authorization.authorize!(AllowAll, @assigns, :new, nil) + end + + test "raises ForbiddenError when not authorized" do + assert_raise Backpex.ForbiddenError, fn -> + Authorization.authorize!(DenyAll, @assigns, :new, nil) + end + end + + test "does not treat a nil item as a missing record" do + assert :ok = Authorization.authorize!(AllowAll, @assigns, :new, nil) + end + end + + describe "authorize_all!/4" do + test "returns :ok when every item is authorized" do + assert :ok = Authorization.authorize_all!(KeyAware, @assigns, :delete, [%{role: :user}]) + end + + test "raises ForbiddenError when a single item is unauthorized" do + assert_raise Backpex.ForbiddenError, fn -> + Authorization.authorize_all!(KeyAware, @assigns, :delete, [%{role: :user}, %{role: :admin}]) + end + end + + test "raises NoResultsError when the list contains nil" do + assert_raise Backpex.NoResultsError, fn -> + Authorization.authorize_all!(AllowAll, @assigns, :delete, [%{role: :user}, nil]) + end + end + + test "never passes nil to the live resource" do + assert_raise Backpex.NoResultsError, fn -> + Authorization.authorize_all!(Recorder, %{test_pid: self()}, :delete, [nil]) + end + + refute_received {:can?, :delete, nil} + end + + test "returns :ok for an empty list" do + assert :ok = Authorization.authorize_all!(DenyAll, @assigns, :delete, []) + end + end +end From 55f912f78d7eb29e121fd7a1e6f2e37380fdd345 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:12:42 +0200 Subject: [PATCH 02/29] Enforce authorization in Backpex.Resource mutations Gate insert/update/delete_all/update_all through Backpex.Authorization before change/6 runs, so user changeset and before_changeset code never executes for an unauthorized request. Breaking changes: - delete_all(items, live_resource) -> delete_all(items, assigns, live_resource, opts) - update_all(items, updates, event_name, live_resource) -> update_all(items, updates, assigns, live_resource, opts) The is_map(assigns) guard makes the old update_all/4 call fail loudly instead of authorizing against the event name string. --- lib/backpex/item_actions/delete.ex | 12 +- lib/backpex/resource.ex | 122 ++++++++++- test/backpex/resource_test.exs | 311 +++++++++++++++++++++++++++++ 3 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 test/backpex/resource_test.exs diff --git a/lib/backpex/item_actions/delete.ex b/lib/backpex/item_actions/delete.ex index e8f88421e..6d979b5a5 100644 --- a/lib/backpex/item_actions/delete.ex +++ b/lib/backpex/item_actions/delete.ex @@ -42,15 +42,23 @@ defmodule Backpex.ItemActions.Delete do @impl Backpex.ItemAction def handle(socket, items, _data) do - {:ok, deleted_items} = Resource.delete_all(items, socket.assigns.live_resource) + %{live_resource: live_resource} = socket.assigns - Enum.each(deleted_items, fn deleted_item -> socket.assigns.live_resource.on_item_deleted(socket, deleted_item) end) + opts = [authorization_action: Map.get(socket.assigns, :item_action_key, :delete)] + + {:ok, deleted_items} = Resource.delete_all(items, socket.assigns, live_resource, opts) + + Enum.each(deleted_items, fn deleted_item -> live_resource.on_item_deleted(socket, deleted_item) end) socket |> clear_flash() |> put_flash(:info, success_message(socket.assigns, deleted_items)) |> ok() rescue + # An authorization failure must reach the router as a 403, not become a flash message. + error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> + reraise error, __STACKTRACE__ + error -> Logger.error("An error occurred while deleting the resource: #{inspect(error)}") diff --git a/lib/backpex/resource.ex b/lib/backpex/resource.ex index 5c1a4c92c..a6d322633 100644 --- a/lib/backpex/resource.ex +++ b/lib/backpex/resource.ex @@ -6,8 +6,43 @@ defmodule Backpex.Resource do > > This module is still under heavy development and will change as we progress with the `Backpex.Adapter` > implementation in the coming releases. Keep this in mind when using this module directly. + + ## Authorization + + All mutations in this module are authorized through `Backpex.Authorization` before anything else + happens — before the changeset is built and before `c:Backpex.Field.before_changeset/6` runs. An + unauthorized call raises `Backpex.ForbiddenError` and never reaches the adapter. + + Each mutation has a default authorization action: + + | function | action | item passed to `c:Backpex.LiveResource.can?/3` | + | --- | --- | --- | + | `insert/6` | `:new` | `nil` | + | `update/6` | `:edit` | the item | + | `update_all/5` | `:edit` | each item | + | `delete_all/4` | `:delete` | each item | + + Two options control this on every mutation: + + * `:authorization_action` (atom) — authorize against this action instead of the default. Item + actions should pass `assigns.item_action_key` so a custom registration key is honored. + * `:authorize?` (boolean, default `true`) — set to `false` to skip the check entirely. This is the + escape hatch for system or cascade writes that are not a user-initiated action on that resource, + for example nullifying foreign keys on another resource. + + Both options are consumed here and never reach `change/6`. + + For lists (`update_all/5`, `delete_all/4`) the check is strict: a single unauthorized item makes + the whole call raise. A `nil` entry (a stale or forged item id) raises `Backpex.NoResultsError`. + An empty list passes without calling the adapter's authorization. + + Reads (`list/4`, `get/4`, `get!/4`, `count/4`) are **not** authorized here. `:index` and `:show` + remain enforced in the view layer — filtering rows after pagination would corrupt counts and + select-all. """ + alias Backpex.Authorization + @doc """ Returns a list of items by given criteria. @@ -67,12 +102,22 @@ defmodule Backpex.Resource do Deletes multiple items. Additionally broadcasts the corresponding event for each deleted item. + Authorizes `:delete` for every item before touching the adapter. See the "Authorization" section + in the module documentation. + ## Parameters * `items` (list): A list of structs, each representing an entity to be deleted. The list must contain items that have an `id` field. + * `assigns` (map): The current assigns of the socket. Passed to `c:Backpex.LiveResource.can?/3`. * `live_resource` (module): The `Backpex.LiveResource` module. + * `opts` (keyword list): A list of options: + * `:authorization_action` (optional, default `:delete`): The action to authorize against. + * `:authorize?` (optional, default `true`): Set to `false` to skip authorization. """ - def delete_all(items, live_resource) do + def delete_all(items, assigns, live_resource, opts \\ []) + when is_list(items) and is_map(assigns) and is_atom(live_resource) do + _opts = authorize_items!(items, assigns, live_resource, opts, :delete) + adapter = live_resource.config(:adapter) adapter.delete_all(items, live_resource) @@ -86,26 +131,50 @@ defmodule Backpex.Resource do @doc """ Inserts a new item into a repository with specific parameters and options. It takes a repo module, a changeset function, an item, parameters for the changeset function, and additional options. + Authorizes `:new` with a `nil` item before the changeset is built. See the "Authorization" section + in the module documentation. + ## Parameters * `item` (struct): The Ecto schema struct. * `attrs` (map): A map of parameters that will be passed to the `changeset_function`. - * TODO: docs + * `fields` (list): The fields for this insert. + * `assigns` (map): The current assigns of the socket. Passed to `c:Backpex.LiveResource.can?/3` and to the changeset function. + * `live_resource` (module): The `Backpex.LiveResource` module. + * `opts` (keyword list): A list of options: + * `:authorization_action` (optional, default `:new`): The action to authorize against. + * `:authorize?` (optional, default `true`): Set to `false` to skip authorization. + * `:after_save_fun` (optional): A function called with the inserted item, returning `{:ok, item}`. + * All remaining options are passed to `change/6`. """ - def insert(item, attrs, fields, assigns, live_resource, opts) do + def insert(item, attrs, fields, assigns, live_resource, opts \\ []) do + opts = authorize_item!(nil, assigns, live_resource, opts, :new) + persist_item(item, attrs, fields, assigns, live_resource, opts, :insert, "created") end @doc """ Handles the update of an existing item with specific parameters and options. It takes a repo module, a changeset function, an item, parameters for the changeset function, and additional options. + Authorizes `:edit` with the given item before the changeset is built. See the "Authorization" + section in the module documentation. + ## Parameters * `item` (struct): The Ecto schema struct. * `attrs` (map): A map of parameters that will be passed to the `changeset_function`. - * TODO: docs + * `fields` (list): The fields for this update. + * `assigns` (map): The current assigns of the socket. Passed to `c:Backpex.LiveResource.can?/3` and to the changeset function. + * `live_resource` (module): The `Backpex.LiveResource` module. + * `opts` (keyword list): A list of options: + * `:authorization_action` (optional, default `:edit`): The action to authorize against. + * `:authorize?` (optional, default `true`): Set to `false` to skip authorization. + * `:after_save_fun` (optional): A function called with the updated item, returning `{:ok, item}`. + * All remaining options are passed to `change/6`. """ def update(item, attrs, fields, assigns, live_resource, opts \\ []) do + opts = authorize_item!(item, assigns, live_resource, opts, :edit) + persist_item(item, attrs, fields, assigns, live_resource, opts, :update, "updated") end @@ -127,14 +196,25 @@ defmodule Backpex.Resource do Updates multiple items from a given repository and schema. Additionally broadcasts the corresponding event, when PubSub config is given. + Authorizes `:edit` for every item before touching the adapter. See the "Authorization" section in + the module documentation. + ## Parameters * `items` (list): A list of structs, each representing an entity to be updated. * `updates` (list): A list of updates passed to Ecto `update_all` function. - * `event_name` (string, default: `updated`): The name to be used when broadcasting the event. + * `assigns` (map): The current assigns of the socket. Passed to `c:Backpex.LiveResource.can?/3`. * `live_resource` (module): The `Backpex.LiveResource` module. + * `opts` (keyword list): A list of options: + * `:event_name` (optional, default `"updated"`): The name to be used when broadcasting the event. + * `:authorization_action` (optional, default `:edit`): The action to authorize against. + * `:authorize?` (optional, default `true`): Set to `false` to skip authorization. """ - def update_all(items, updates, event_name \\ "updated", live_resource) do + def update_all(items, updates, assigns, live_resource, opts \\ []) + when is_list(items) and is_map(assigns) and is_atom(live_resource) do + opts = authorize_items!(items, assigns, live_resource, opts, :edit) + + event_name = Keyword.get(opts, :event_name, "updated") adapter = live_resource.config(:adapter) case adapter.update_all(items, updates, live_resource) do @@ -147,6 +227,36 @@ defmodule Backpex.Resource do end end + # Pops the authorization options and runs the gate for a single item. Returns the remaining opts, + # so `:authorization_action` and `:authorize?` never reach `change/6`. + defp authorize_item!(item, assigns, live_resource, opts, default_action) do + {authorize?, authorization_action, opts} = pop_authorization_opts(opts, default_action) + + if authorize?, do: Authorization.authorize!(live_resource, assigns, authorization_action, item) + + opts + end + + # Same as `authorize_item!/5`, but strict over a list of items. + defp authorize_items!(items, assigns, live_resource, opts, default_action) do + {authorize?, authorization_action, opts} = pop_authorization_opts(opts, default_action) + + if authorize?, do: Authorization.authorize_all!(live_resource, assigns, authorization_action, items) + + opts + end + + defp pop_authorization_opts(opts, default_action) do + {authorize?, opts} = Keyword.pop(opts, :authorize?, true) + {authorization_action, opts} = Keyword.pop(opts, :authorization_action, default_action) + + if !is_boolean(authorize?) do + raise ArgumentError, "expected :authorize? to be a boolean, got: #{inspect(authorize?)}" + end + + {authorize?, authorization_action, opts} + end + @doc """ Applies a change to a given item by calling the specified changeset function. In addition, puts the given assocs into the function and calls the `c:Backpex.Field.before_changeset/6` callback for each field. diff --git a/test/backpex/resource_test.exs b/test/backpex/resource_test.exs new file mode 100644 index 000000000..46a91b63c --- /dev/null +++ b/test/backpex/resource_test.exs @@ -0,0 +1,311 @@ +defmodule Backpex.ResourceTest do + use ExUnit.Case, async: true + + alias Backpex.Resource + alias Backpex.ResourceTest.PubSub + + @pubsub_server PubSub + @topic "backpex_resource_test" + + defmodule StubAdapter do + @moduledoc false + + # Every call reports back to the test process. Tests use `refute_received/1` to prove that a + # denied mutation never reached the data layer. + + def change(item, attrs, _fields, _assigns, _live_resource, opts) do + send(self(), {:adapter, :change, opts}) + + {:changeset, item, attrs} + end + + def insert({:changeset, item, attrs}, _live_resource) do + send(self(), {:adapter, :insert, item, attrs}) + + {:ok, item} + end + + def update({:changeset, item, attrs}, _live_resource) do + send(self(), {:adapter, :update, item, attrs}) + + {:ok, item} + end + + def delete_all(items, _live_resource) do + send(self(), {:adapter, :delete_all, items}) + + {:ok, items} + end + + def update_all(items, updates, _live_resource) do + send(self(), {:adapter, :update_all, items, updates}) + + {length(items), nil} + end + end + + defmodule AllowAll do + @moduledoc false + def config(:adapter), do: Backpex.ResourceTest.StubAdapter + def can?(_assigns, _action, _item), do: true + def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] + end + + defmodule DenyAll do + @moduledoc false + def config(:adapter), do: Backpex.ResourceTest.StubAdapter + def can?(_assigns, _action, _item), do: false + def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] + end + + defmodule Recording do + @moduledoc false + def config(:adapter), do: Backpex.ResourceTest.StubAdapter + + def can?(_assigns, action, item) do + send(self(), {:can?, action, item}) + + true + end + + def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] + end + + defmodule OnlyCustomKey do + @moduledoc false + def config(:adapter), do: Backpex.ResourceTest.StubAdapter + def can?(_assigns, :custom_key, _item), do: true + def can?(_assigns, _action, _item), do: false + def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] + end + + defmodule NoAdmins do + @moduledoc false + def config(:adapter), do: Backpex.ResourceTest.StubAdapter + def can?(_assigns, _action, %{role: :admin} = _item), do: false + def can?(_assigns, _action, _item), do: true + def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] + end + + setup do + start_supervised!({Phoenix.PubSub, name: @pubsub_server}) + :ok = Phoenix.PubSub.subscribe(@pubsub_server, @topic) + + %{assigns: %{some: :assign}, item: %{id: 1}, fields: []} + end + + describe "insert/6" do + test "authorizes :new with a nil item before building the changeset", %{assigns: assigns, item: item, fields: f} do + assert {:ok, ^item} = Resource.insert(item, %{}, f, assigns, Recording) + + assert_received {:can?, :new, nil} + end + + test "raises before change/6 when not authorized", %{assigns: assigns, item: item, fields: f} do + assert_raise Backpex.ForbiddenError, fn -> + Resource.insert(item, %{}, f, assigns, DenyAll) + end + + refute_received {:adapter, :change, _opts} + refute_received {:adapter, :insert, _item, _attrs} + end + + test "broadcasts on success", %{assigns: assigns, item: item, fields: f} do + assert {:ok, ^item} = Resource.insert(item, %{}, f, assigns, AllowAll) + + assert_received {"created", ^item} + assert_received {"backpex:created", ^item} + end + + test "honors :authorization_action", %{assigns: assigns, item: item, fields: f} do + assert_raise Backpex.ForbiddenError, fn -> + Resource.insert(item, %{}, f, assigns, OnlyCustomKey) + end + + assert {:ok, ^item} = + Resource.insert(item, %{}, f, assigns, OnlyCustomKey, authorization_action: :custom_key) + end + + test "honors authorize?: false", %{assigns: assigns, item: item, fields: f} do + assert {:ok, ^item} = Resource.insert(item, %{}, f, assigns, DenyAll, authorize?: false) + + assert_received {:adapter, :insert, ^item, _attrs} + end + + test "does not leak authorization options into change/6", %{assigns: assigns, item: item, fields: f} do + opts = [authorization_action: :custom_key, authorize?: true, assocs: [tags: []]] + + assert {:ok, ^item} = Resource.insert(item, %{}, f, assigns, OnlyCustomKey, opts) + + assert_received {:adapter, :change, change_opts} + refute Keyword.has_key?(change_opts, :authorization_action) + refute Keyword.has_key?(change_opts, :authorize?) + assert Keyword.get(change_opts, :assocs) == [tags: []] + assert Keyword.get(change_opts, :action) == :insert + end + + test "raises when :authorize? is not a boolean", %{assigns: assigns, item: item, fields: f} do + assert_raise ArgumentError, fn -> + Resource.insert(item, %{}, f, assigns, AllowAll, authorize?: :nope) + end + end + end + + describe "update/6" do + test "authorizes :edit with the item", %{assigns: assigns, item: item, fields: f} do + assert {:ok, ^item} = Resource.update(item, %{}, f, assigns, Recording) + + assert_received {:can?, :edit, ^item} + end + + test "raises before change/6 when not authorized", %{assigns: assigns, item: item, fields: f} do + assert_raise Backpex.ForbiddenError, fn -> + Resource.update(item, %{}, f, assigns, DenyAll) + end + + refute_received {:adapter, :change, _opts} + refute_received {:adapter, :update, _item, _attrs} + end + + test "broadcasts on success", %{assigns: assigns, item: item, fields: f} do + assert {:ok, ^item} = Resource.update(item, %{}, f, assigns, AllowAll) + + assert_received {"updated", ^item} + assert_received {"backpex:updated", ^item} + end + + test "honors :authorization_action and authorize?: false", %{assigns: assigns, item: item, fields: f} do + assert {:ok, ^item} = + Resource.update(item, %{}, f, assigns, OnlyCustomKey, authorization_action: :custom_key) + + assert {:ok, ^item} = Resource.update(item, %{}, f, assigns, DenyAll, authorize?: false) + end + end + + describe "delete_all/4" do + test "authorizes :delete per item", %{assigns: assigns} do + items = [%{id: 1}, %{id: 2}] + + assert {:ok, ^items} = Resource.delete_all(items, assigns, Recording) + + assert_received {:can?, :delete, %{id: 1}} + assert_received {:can?, :delete, %{id: 2}} + end + + test "raises for the whole call when a single item is unauthorized", %{assigns: assigns} do + items = [%{id: 1, role: :user}, %{id: 2, role: :admin}] + + assert_raise Backpex.ForbiddenError, fn -> + Resource.delete_all(items, assigns, NoAdmins) + end + + refute_received {:adapter, :delete_all, _items} + end + + test "raises NoResultsError when the list contains nil", %{assigns: assigns} do + assert_raise Backpex.NoResultsError, fn -> + Resource.delete_all([%{id: 1}, nil], assigns, AllowAll) + end + + refute_received {:adapter, :delete_all, _items} + end + + test "passes an empty list vacuously", %{assigns: assigns} do + assert {:ok, []} = Resource.delete_all([], assigns, DenyAll) + + assert_received {:adapter, :delete_all, []} + end + + test "honors :authorization_action and authorize?: false", %{assigns: assigns} do + items = [%{id: 1}] + + assert {:ok, ^items} = Resource.delete_all(items, assigns, OnlyCustomKey, authorization_action: :custom_key) + assert {:ok, ^items} = Resource.delete_all(items, assigns, DenyAll, authorize?: false) + end + + test "broadcasts a deleted event per item", %{assigns: assigns} do + item = %{id: 1} + + assert {:ok, _items} = Resource.delete_all([item], assigns, AllowAll) + + assert_received {"deleted", ^item} + assert_received {"backpex:deleted", ^item} + end + + test "does not answer the pre-0.21 delete_all/2 signature" do + # `apply/3` keeps the compiler's type checker out of it — the point is the runtime behavior. + assert_raise UndefinedFunctionError, fn -> + apply(Resource, :delete_all, [[%{id: 1}], AllowAll]) + end + end + end + + describe "update_all/5" do + test "authorizes :edit per item", %{assigns: assigns} do + items = [%{id: 1}, %{id: 2}] + + assert {:ok, ^items} = Resource.update_all(items, [set: [x: 1]], assigns, Recording) + + assert_received {:can?, :edit, %{id: 1}} + assert_received {:can?, :edit, %{id: 2}} + end + + test "raises for the whole call when a single item is unauthorized", %{assigns: assigns} do + items = [%{id: 1, role: :user}, %{id: 2, role: :admin}] + + assert_raise Backpex.ForbiddenError, fn -> + Resource.update_all(items, [set: [x: 1]], assigns, NoAdmins) + end + + refute_received {:adapter, :update_all, _items, _updates} + end + + test "raises NoResultsError when the list contains nil", %{assigns: assigns} do + assert_raise Backpex.NoResultsError, fn -> + Resource.update_all([%{id: 1}, nil], [set: [x: 1]], assigns, AllowAll) + end + + refute_received {:adapter, :update_all, _items, _updates} + end + + test "passes an empty list vacuously", %{assigns: assigns} do + assert {:ok, []} = Resource.update_all([], [set: [x: 1]], assigns, DenyAll) + end + + test "broadcasts the default event", %{assigns: assigns} do + item = %{id: 1} + + assert {:ok, _items} = Resource.update_all([item], [set: [x: 1]], assigns, AllowAll) + + assert_received {"updated", ^item} + assert_received {"backpex:updated", ^item} + end + + test "honors :event_name", %{assigns: assigns} do + item = %{id: 1} + + assert {:ok, _items} = Resource.update_all([item], [set: [x: 1]], assigns, AllowAll, event_name: "deleted") + + assert_received {"deleted", ^item} + assert_received {"backpex:deleted", ^item} + end + + test "honors :authorization_action and authorize?: false", %{assigns: assigns} do + items = [%{id: 1}] + + assert {:ok, ^items} = + Resource.update_all(items, [set: [x: 1]], assigns, OnlyCustomKey, authorization_action: :custom_key) + + assert {:ok, ^items} = Resource.update_all(items, [set: [x: 1]], assigns, DenyAll, authorize?: false) + end + + test "raises FunctionClauseError for the pre-0.21 update_all/4 signature", %{assigns: _assigns} do + # `update_all(items, updates, "deleted", MyLive)` has the same arity as the new + # `update_all(items, updates, assigns, live_resource)`. The `is_map(assigns)` guard makes the + # old call fail loudly instead of silently authorizing against a string. + assert_raise FunctionClauseError, fn -> + apply(Resource, :update_all, [[%{id: 1}], [set: [x: 1]], "deleted", AllowAll]) + end + end + end +end From b9bdde4755ae361ce0b7172fc3f89eb7bd58e2e2 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:14:05 +0200 Subject: [PATCH 03/29] Harden item id and action key entry points Resolve client-supplied action keys against the registered actions instead of String.to_existing_atom/1, so an unknown key raises Backpex.NoResultsError rather than an ArgumentError. Reject stale or forged item ids at the item-action event and ignore them for update-selected-items, so nil never enters selected_items and never reaches the user's can?/3. --- lib/backpex/live_resource.ex | 24 +++++++++++++++ lib/backpex/live_resource/index.ex | 47 +++++++++++++++++------------- lib/backpex/live_resource/show.ex | 4 +-- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/lib/backpex/live_resource.ex b/lib/backpex/live_resource.ex index e68d86f7b..bab4372a9 100644 --- a/lib/backpex/live_resource.ex +++ b/lib/backpex/live_resource.ex @@ -1052,4 +1052,28 @@ defmodule Backpex.LiveResource do end defp safe_return_to?(_path), do: false + + @doc """ + Resolves a client-supplied action key against a keyword list of registered actions. + + Returns `{key, action}` for the matching registration and raises `Backpex.NoResultsError` when the + key is not registered. + + The key is matched by comparing binaries rather than by `String.to_existing_atom/1`: a forged key + must produce the same 404 as an unknown one, not an `ArgumentError` that depends on which atoms + happen to exist in the running system. + + ## Examples + + iex> Backpex.LiveResource.fetch_action!([delete: %{module: Backpex.ItemActions.Delete}], "delete") + {:delete, %{module: Backpex.ItemActions.Delete}} + """ + def fetch_action!(actions, key) when is_list(actions) and is_binary(key) do + case Enum.find(actions, fn {registered_key, _action} -> Atom.to_string(registered_key) == key end) do + nil -> raise Backpex.NoResultsError + registration -> registration + end + end + + def fetch_action!(_actions, _key), do: raise(Backpex.NoResultsError) end diff --git a/lib/backpex/live_resource/index.ex b/lib/backpex/live_resource/index.ex index 1b149ef93..bc9221a1e 100644 --- a/lib/backpex/live_resource/index.ex +++ b/lib/backpex/live_resource/index.ex @@ -133,7 +133,9 @@ defmodule Backpex.LiveResource.Index do def handle_event("item-action", %{"action-key" => key, "item-id" => item_id}, socket) do %{items: items, live_resource: live_resource} = socket.assigns - item = find_item_by_primary_value(items, item_id, live_resource) + # A stale or forged id must never enter the selection: `nil` would reach the user's `can?/3` + # during render. 404 also keeps the event from confirming whether an id exists. + item = find_item_by_primary_value(items, item_id, live_resource) || raise(Backpex.NoResultsError) socket |> assign(selected_items: [item]) @@ -201,21 +203,27 @@ defmodule Backpex.LiveResource.Index do def handle_event("update-selected-items", %{"id" => id}, socket) do %{selected_items: selected_items, live_resource: live_resource, items: items} = socket.assigns - item = find_item_by_primary_value(items, id, live_resource) + # `id` is client-controlled. A tampered or stale id must be a no-op rather than putting `nil` + # into the selection, where it would reach the user's `can?/3` on the next render. + case find_item_by_primary_value(items, id, live_resource) do + nil -> + noreply(socket) - updated_selected_items = - if Enum.member?(selected_items, item) do - List.delete(selected_items, item) - else - [item | selected_items] - end + item -> + updated_selected_items = + if Enum.member?(selected_items, item) do + List.delete(selected_items, item) + else + [item | selected_items] + end - select_all = length(updated_selected_items) == length(items) + select_all = length(updated_selected_items) == length(items) - socket - |> assign(:selected_items, updated_selected_items) - |> assign(:select_all, select_all) - |> noreply() + socket + |> assign(:selected_items, updated_selected_items) + |> assign(:select_all, select_all) + |> noreply() + end end def handle_event("toggle-item-selection", _params, socket) do @@ -292,8 +300,7 @@ defmodule Backpex.LiveResource.Index do end defp maybe_handle_item_action(socket, key) do - key = String.to_existing_atom(key) - action = socket.assigns.item_actions[key] + {key, action} = LiveResource.fetch_action!(socket.assigns.item_actions, key) items = socket.assigns.selected_items if Backpex.ItemAction.has_confirm_modal?(action) do @@ -490,12 +497,10 @@ defmodule Backpex.LiveResource.Index do defp apply_action(socket, :resource_action) do %{live_resource: live_resource} = socket.assigns - id = - socket.assigns.params["backpex_id"] - |> URI.decode() - |> String.to_existing_atom() - - action = live_resource.resource_actions()[id] + # The id comes from the URL: resolve it against the registered resource actions instead of + # atomizing it, so an unknown action is a 404 rather than an ArgumentError. + {id, action} = + LiveResource.fetch_action!(live_resource.resource_actions(), URI.decode(socket.assigns.params["backpex_id"])) Authorization.authorize!(live_resource, socket.assigns, id, nil) diff --git a/lib/backpex/live_resource/show.ex b/lib/backpex/live_resource/show.ex index 3100c002e..2839503a2 100644 --- a/lib/backpex/live_resource/show.ex +++ b/lib/backpex/live_resource/show.ex @@ -5,6 +5,7 @@ defmodule Backpex.LiveResource.Show do import Phoenix.Component alias Backpex.Authorization + alias Backpex.LiveResource alias Backpex.Resource alias Backpex.Router @@ -75,8 +76,7 @@ defmodule Backpex.LiveResource.Show do end defp maybe_handle_item_action(socket, key) do - key = String.to_existing_atom(key) - action = socket.assigns.item_actions[key] + {key, action} = LiveResource.fetch_action!(socket.assigns.item_actions, key) item = socket.assigns.item if Backpex.ItemAction.has_confirm_modal?(action) do From 33c4abfaff173b3296a3bc31e65606ddbe364254 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:16:19 +0200 Subject: [PATCH 04/29] Gate item and resource actions before dispatch Item actions authorize every selected item before the confirm modal opens and again immediately before handle/3, and receive assigns.item_action_key. Resource action submits re-check the resource action key, closing the window between opening the modal and submitting it. Item action submits now take the action key from the server-side action_to_confirm assign instead of the phx-value-action-key DOM parameter, which the client could set to any registered key. --- .../html/resource/form_component.html.heex | 7 -- lib/backpex/item_actions/item_action.ex | 40 +++++--- lib/backpex/live_components/form_component.ex | 53 ++++++++-- lib/backpex/live_resource/index.ex | 4 + lib/backpex/live_resource/show.ex | 4 + test/backpex/item_action_test.exs | 97 +++++++++++++++++++ 6 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 test/backpex/item_action_test.exs diff --git a/lib/backpex/html/resource/form_component.html.heex b/lib/backpex/html/resource/form_component.html.heex index 53457252c..c2aeeb1bd 100644 --- a/lib/backpex/html/resource/form_component.html.heex +++ b/lib/backpex/html/resource/form_component.html.heex @@ -6,13 +6,6 @@ phx-target={@myself} phx-change="validate" phx-submit="save" - phx-value-action-key={ - if @action_type == :item do - @action_to_confirm.key - else - nil - end - } multipart > <.edit_card> diff --git a/lib/backpex/item_actions/item_action.ex b/lib/backpex/item_actions/item_action.ex index 1a58def9e..ec91f4e18 100644 --- a/lib/backpex/item_actions/item_action.ex +++ b/lib/backpex/item_actions/item_action.ex @@ -265,26 +265,40 @@ defmodule Backpex.ItemAction do @doc """ Handles an item action by executing the action's handle function. - This function filters items based on authorization, executes the action, - and allows customization of post-action behavior via the `after_handle` callback. + Every item is authorized against `key` before `c:handle/3` runs. This is strict: a single + unauthorized item raises `Backpex.ForbiddenError`, and a `nil` entry (a stale or forged item id) + raises `Backpex.NoResultsError`. Items are never silently dropped from the selection. + + `c:handle/3` receives the full list of items, and `assigns.item_action_key` is set to the key the + action is registered under. Pass it as `:authorization_action` to `Backpex.Resource` functions so + actions registered under a custom key authorize against that key. + + When `items` is empty, `c:handle/3` is not called at all — only `after_handle` runs. """ def handle_item_action(socket, action, key, items, after_handle) do live_resource = socket.assigns.live_resource - authorized_items = Enum.filter(items, fn item -> live_resource.can?(socket.assigns, key, item) end) - case action.module.handle(socket, authorized_items, %{}) do - {:ok, socket} -> - after_handle.(socket) + Backpex.Authorization.authorize_all!(live_resource, socket.assigns, key, items) - unexpected_return -> - raise ArgumentError, """ - Invalid return value from #{inspect(action.module)}.handle/3. + if items == [] do + after_handle.(socket) + else + socket = assign(socket, :item_action_key, key) - Expected: {:ok, socket} - Got: #{inspect(unexpected_return)} + case action.module.handle(socket, items, %{}) do + {:ok, socket} -> + after_handle.(socket) - Item Actions with no form fields must return {:ok, socket}. - """ + unexpected_return -> + raise ArgumentError, """ + Invalid return value from #{inspect(action.module)}.handle/3. + + Expected: {:ok, socket} + Got: #{inspect(unexpected_return)} + + Item Actions with no form fields must return {:ok, socket}. + """ + end end end diff --git a/lib/backpex/live_components/form_component.ex b/lib/backpex/live_components/form_component.ex index 9ca5410b7..622cb3baa 100644 --- a/lib/backpex/live_components/form_component.ex +++ b/lib/backpex/live_components/form_component.ex @@ -5,6 +5,7 @@ defmodule Backpex.FormComponent do use BackpexWeb, :html use Phoenix.LiveComponent + alias Backpex.Authorization alias Backpex.Field alias Backpex.ItemAction alias Backpex.LiveResource @@ -178,9 +179,10 @@ defmodule Backpex.FormComponent do |> noreply() end - def handle_event("save", %{"action-key" => key, "change" => change}, %{assigns: %{action_type: :item}} = socket) do - key = String.to_existing_atom(key) - handle_form_item_action(socket, key, change) + # The action to run is taken from `action_to_confirm`, which the view assigned when the modal was + # opened. A client-supplied action key must never decide which module executes. + def handle_event("save", %{"change" => change}, %{assigns: %{action_type: :item}} = socket) do + handle_form_item_action(socket, change) end def handle_event("save", %{"change" => change, "save-type" => save_type}, socket) do @@ -195,9 +197,8 @@ defmodule Backpex.FormComponent do handle_save(socket, live_action, change, save_type) end - def handle_event("save", %{"action-key" => key}, socket) do - key = String.to_existing_atom(key) - handle_form_item_action(socket, key, %{}) + def handle_event("save", _params, %{assigns: %{action_type: :item}} = socket) do + handle_form_item_action(socket, %{}) end def handle_event("save", _params, socket) do @@ -316,6 +317,10 @@ defmodule Backpex.FormComponent do } = assigns } = socket + # The gate at mount only covers opening the modal. Re-check on submit so a permission revoked + # while the form was open cannot be used. + Authorization.authorize!(live_resource, assigns, assigns.resource_action_id, nil) + assocs = Map.get(assigns, :assocs, []) params = drop_readonly_changes(params, fields, assigns) @@ -355,11 +360,42 @@ defmodule Backpex.FormComponent do end end - defp handle_form_item_action(socket, action_key, params) do + defp handle_form_item_action(socket, params) do %{ assigns: %{ live_resource: live_resource, + selected_items: selected_items, + action_to_confirm: action_to_confirm, + return_to: return_to + } = assigns + } = socket + + action_key = action_to_confirm.key + + # Gate before any changeset work: permission may have been revoked while the modal was open. + Authorization.authorize_all!(live_resource, assigns, action_key, selected_items) + + if selected_items == [] do + empty_item_action_selection(socket, return_to) + else + run_form_item_action(socket, action_key, params) + end + end + + defp empty_item_action_selection(socket, return_to) do + socket + |> assign(:show_form_errors, false) + |> assign(:selected_items, []) + |> assign(:select_all, false) + |> push_navigate(to: return_to) + |> noreply() + end + + defp run_form_item_action(socket, action_key, params) do + %{ + assigns: + %{ fields: fields, selected_items: selected_items, action_to_confirm: action_to_confirm, @@ -385,8 +421,9 @@ defmodule Backpex.FormComponent do {:ok, %{}} end + socket = assign(socket, :item_action_key, action_key) + with {:ok, data} <- result, - selected_items = Enum.filter(selected_items, &live_resource.can?(socket.assigns, action_key, &1)), {:ok, socket} <- action_to_confirm.module.handle(socket, selected_items, data) do socket |> assign(:show_form_errors, false) diff --git a/lib/backpex/live_resource/index.ex b/lib/backpex/live_resource/index.ex index bc9221a1e..d7b2d8cb3 100644 --- a/lib/backpex/live_resource/index.ex +++ b/lib/backpex/live_resource/index.ex @@ -303,6 +303,10 @@ defmodule Backpex.LiveResource.Index do {key, action} = LiveResource.fetch_action!(socket.assigns.item_actions, key) items = socket.assigns.selected_items + # Gate before the modal opens: an unauthorized selection must not even get a confirm dialog. + # `Backpex.ItemAction.handle_item_action/5` checks again as defense in depth. + Authorization.authorize_all!(socket.assigns.live_resource, socket.assigns, key, items) + if Backpex.ItemAction.has_confirm_modal?(action) do open_action_confirm_modal(socket, action, key) else diff --git a/lib/backpex/live_resource/show.ex b/lib/backpex/live_resource/show.ex index 2839503a2..564f5069d 100644 --- a/lib/backpex/live_resource/show.ex +++ b/lib/backpex/live_resource/show.ex @@ -79,6 +79,10 @@ defmodule Backpex.LiveResource.Show do {key, action} = LiveResource.fetch_action!(socket.assigns.item_actions, key) item = socket.assigns.item + # Gate before the modal opens: an unauthorized item must not even get a confirm dialog. + # `Backpex.ItemAction.handle_item_action/5` checks again as defense in depth. + Authorization.authorize_all!(socket.assigns.live_resource, socket.assigns, key, [item]) + if Backpex.ItemAction.has_confirm_modal?(action) do open_action_confirm_modal(socket, action, key) else diff --git a/test/backpex/item_action_test.exs b/test/backpex/item_action_test.exs new file mode 100644 index 000000000..03baef775 --- /dev/null +++ b/test/backpex/item_action_test.exs @@ -0,0 +1,97 @@ +defmodule Backpex.ItemActionTest do + use ExUnit.Case, async: true + + alias Backpex.ItemAction + alias Phoenix.LiveView.Socket + + defmodule AllowAll do + @moduledoc false + def can?(_assigns, _action, _item), do: true + end + + defmodule NoAdmins do + @moduledoc false + def can?(_assigns, _action, %{role: :admin} = _item), do: false + def can?(_assigns, _action, _item), do: true + end + + defmodule EchoAction do + @moduledoc false + def handle(socket, items, _data) do + send(self(), {:handled, items, Map.get(socket.assigns, :item_action_key)}) + + {:ok, socket} + end + end + + defmodule BadReturnAction do + @moduledoc false + def handle(_socket, _items, _data), do: :oops + end + + defp build_socket(live_resource) do + Phoenix.Component.assign(%Socket{}, :live_resource, live_resource) + end + + defp after_handle(socket), do: {:after_handle, socket} + + describe "handle_item_action/5" do + test "passes the full list to handle/3 and assigns item_action_key" do + items = [%{id: 1, role: :user}, %{id: 2, role: :user}] + + assert {:after_handle, _socket} = + ItemAction.handle_item_action( + build_socket(AllowAll), + %{module: EchoAction}, + :user_soft_delete, + items, + &after_handle/1 + ) + + assert_received {:handled, ^items, :user_soft_delete} + end + + test "raises ForbiddenError when a single item is unauthorized and never calls handle/3" do + items = [%{id: 1, role: :user}, %{id: 2, role: :admin}] + + assert_raise Backpex.ForbiddenError, fn -> + ItemAction.handle_item_action(build_socket(NoAdmins), %{module: EchoAction}, :delete, items, &after_handle/1) + end + + refute_received {:handled, _items, _key} + end + + test "raises NoResultsError for a nil item and never calls handle/3" do + assert_raise Backpex.NoResultsError, fn -> + ItemAction.handle_item_action(build_socket(AllowAll), %{module: EchoAction}, :delete, [nil], &after_handle/1) + end + + refute_received {:handled, _items, _key} + end + + test "never calls handle/3 for an empty selection but still runs after_handle" do + assert {:after_handle, _socket} = + ItemAction.handle_item_action( + build_socket(AllowAll), + %{module: EchoAction}, + :delete, + [], + &after_handle/1 + ) + + refute_received {:handled, _items, _key} + end + + test "raises ArgumentError on an unexpected return value" do + assert_raise ArgumentError, ~r/Invalid return value/, fn -> + ItemAction.handle_item_action( + build_socket(AllowAll), + %{module: BadReturnAction}, + :delete, + [%{id: 1}], + &after_handle/1 + ) + end + end + end +end From e65795cf3929f98de673e634e3bbf7f11cb5a1de Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:16:43 +0200 Subject: [PATCH 05/29] Align action button state with strict enforcement Disable a bulk item action button when the selection is empty or contains any unauthorized item, matching the strict gate. Drop the duplicate can?(:edit) check from index-editable fields, which Backpex.Resource.update/6 now enforces at the same effective point. --- lib/backpex/field.ex | 7 +++---- lib/backpex/html/resource.ex | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index b73072605..b8f7cd54e 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -424,10 +424,9 @@ defmodule Backpex.Field do def handle_index_editable(socket, value, change) do %{assigns: %{item: item, fields: fields, live_resource: live_resource} = assigns} = socket - if not live_resource.can?(assigns, :edit, item) do - raise Backpex.ForbiddenError - end - + # No `can?/3` check here: `Backpex.Resource.update/6` enforces `:edit` with the same assigns and + # item, before the changeset runs. Checking here as well would evaluate user code twice per + # inline edit for no added protection. opts = [ after_save_fun: fn item -> live_resource.on_item_updated(socket, item) diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 85cff7093..517dee09b 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -972,11 +972,11 @@ defmodule Backpex.HTML.Resource do end) end + # Enforcement is strict: a selection containing a single unauthorized item raises. So the button + # must be disabled unless *every* selected item is authorized. `Enum.all?([]) == true`, so the + # empty selection has to be handled explicitly. defp action_disabled?(assigns, action_key, items) do - Enum.filter(items, fn item -> - assigns.live_resource.can?(assigns, action_key, item) - end) - |> Enum.empty?() + items == [] or not Backpex.Authorization.can_all?(assigns.live_resource, assigns, action_key, items) end @doc """ From 13069ff975e62f560f4115443bf700bfca1ac9f1 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:25:08 +0200 Subject: [PATCH 06/29] Migrate demo soft delete to the new Resource API Pass assigns and the item action key to update_all, use authorize?: false for the cross-resource post nullification cascade, and reraise ForbiddenError from the rescue instead of turning it into a flash message. --- .../demo_web/item_actions/user_soft_delete.ex | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/demo/lib/demo_web/item_actions/user_soft_delete.ex b/demo/lib/demo_web/item_actions/user_soft_delete.ex index 0ccb3bd12..cc60b345b 100644 --- a/demo/lib/demo_web/item_actions/user_soft_delete.ex +++ b/demo/lib/demo_web/item_actions/user_soft_delete.ex @@ -68,19 +68,30 @@ defmodule DemoWeb.ItemActions.UserSoftDelete do updates = [set: [deleted_at: datetime]] {:ok, _count} = - Backpex.Resource.update_all(items, updates, "deleted", socket.assigns.live_resource) + Backpex.Resource.update_all(items, updates, socket.assigns, socket.assigns.live_resource, + authorization_action: socket.assigns.item_action_key, + event_name: "deleted" + ) - # nullify the user_id in the posts owned by the users + # nullify the user_id in the posts owned by the users. This is a cascade write on another + # resource, not a user-initiated action on it, so it skips authorization deliberately. _nullified_posts = items |> Enum.map(fn item -> - Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], "updated", DemoWeb.PostLive) + Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, DemoWeb.PostLive, + event_name: "updated", + authorize?: false + ) end) socket |> clear_flash() |> put_flash(:info, success_message(socket.assigns, items)) rescue + # An authorization failure must reach the router as a 403, not become a flash message. + error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> + reraise error, __STACKTRACE__ + error -> Logger.error("An error occurred while deleting the resource: #{inspect(error)}") From 03741e3a836e92b17254da76b7e7a06a1bd4a6d3 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:25:13 +0200 Subject: [PATCH 07/29] Add demo integration tests for authorization enforcement Cover forged item-action events, unknown item ids and action keys, forged selection ids, mixed selections, the submit-time re-check and the reraise clauses in the built-in and demo delete actions. --- .../live/authorization_enforcement_test.exs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 demo/test/demo_web/live/authorization_enforcement_test.exs diff --git a/demo/test/demo_web/live/authorization_enforcement_test.exs b/demo/test/demo_web/live/authorization_enforcement_test.exs new file mode 100644 index 000000000..ace006007 --- /dev/null +++ b/demo/test/demo_web/live/authorization_enforcement_test.exs @@ -0,0 +1,206 @@ +defmodule DemoWeb.Live.AuthorizationEnforcementTest do + @moduledoc """ + End-to-end checks that Backpex enforces `can?/3` server-side. + + Every test here forges an event the UI would never send: the buttons are hidden or disabled, so + the only way to reach these code paths is a tampered payload. + """ + use DemoWeb.ConnCase, async: false + + import Demo.EctoFactory + import Phoenix.LiveViewTest + + alias Demo.Repo + alias Demo.ShortLink + alias Demo.User + alias Phoenix.LiveView.Socket + + @moduletag :capture_log + + setup do + # `live/2` links the LiveView to the test process. These tests deliberately make it crash, so + # the EXIT signal has to arrive as a message instead of killing the test. + Process.flag(:trap_exit, true) + + :ok + end + + defp assign_socket(assigns) do + Enum.reduce(assigns, %Socket{}, fn {key, value}, socket -> + Phoenix.Component.assign(socket, key, value) + end) + end + + describe "forged item actions on a resource that denies the action" do + setup do + product = insert(:product) + + {:ok, short_link} = + Repo.insert(%ShortLink{short_key: "forgedkey", url: "https://example.com", product_id: product.id}) + + %{short_link: short_link} + end + + test "raises ForbiddenError and keeps the record", %{conn: conn, short_link: short_link} do + {:ok, view, _html} = live(conn, ~p"/admin/short-links") + + assert {{%Backpex.ForbiddenError{}, _stacktrace}, _mfa} = + catch_exit( + render_click(view, "item-action", %{"action-key" => "delete", "item-id" => short_link.short_key}) + ) + + assert Repo.get_by(ShortLink, short_key: "forgedkey") + end + end + + describe "forged item actions with a confirmation modal" do + test "an unauthorized item raises before the modal opens", %{conn: conn} do + admin = insert(:user, %{role: :admin}) + + {:ok, view, _html} = live(conn, ~p"/admin/users") + + assert {{%Backpex.ForbiddenError{}, _stacktrace}, _mfa} = + catch_exit(render_click(view, "item-action", %{"action-key" => "user_soft_delete", "item-id" => admin.id})) + + assert Repo.get(User, admin.id).deleted_at == nil + end + + test "a nonexistent item id raises NoResultsError", %{conn: conn} do + insert(:user) + + {:ok, view, _html} = live(conn, ~p"/admin/users") + + assert {{%Backpex.NoResultsError{}, _stacktrace}, _mfa} = + catch_exit(render_click(view, "item-action", %{"action-key" => "user_soft_delete", "item-id" => "0"})) + end + end + + describe "forged action keys" do + test "an unknown key on a row raises NoResultsError, not ArgumentError", %{conn: conn} do + user = insert(:user) + + {:ok, view, _html} = live(conn, ~p"/admin/users") + + assert {{%Backpex.NoResultsError{}, _stacktrace}, _mfa} = + catch_exit( + render_click(view, "item-action", %{ + "action-key" => "no_such_backpex_item_action_key", + "item-id" => user.id + }) + ) + end + + test "an unknown key on the toolbar raises NoResultsError, not ArgumentError", %{conn: conn} do + insert(:user) + + {:ok, view, _html} = live(conn, ~p"/admin/users") + + assert {{%Backpex.NoResultsError{}, _stacktrace}, _mfa} = + catch_exit(render_click(view, "item-action", %{"action-key" => "another_missing_action_key"})) + end + end + + describe "forged selection ids" do + test "an unknown id is ignored and never enters the selection", %{conn: conn} do + user = insert(:user) + + {:ok, view, _html} = live(conn, ~p"/admin/users") + + # A nil in `selected_items` would blow up in DemoWeb.UserLive.can?/3 on the next render. + render_click(view, "update-selected-items", %{"id" => "0"}) + + assert has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + refute has_element?(view, "#select-input-#{user.id}[checked]") + + render_click(view, "update-selected-items", %{"id" => user.id}) + + assert has_element?(view, "#select-input-#{user.id}[checked]") + refute has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + end + end + + describe "mixed selections" do + setup do + %{user: insert(:user, %{role: :user}), admin: insert(:user, %{role: :admin})} + end + + test "disable the bulk action button", %{conn: conn, user: user, admin: admin} do + {:ok, view, _html} = live(conn, ~p"/admin/users") + + render_click(view, "update-selected-items", %{"id" => user.id}) + refute has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + + render_click(view, "update-selected-items", %{"id" => admin.id}) + assert has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + end + + test "raise ForbiddenError when the bulk action is forged anyway", %{conn: conn, user: user, admin: admin} do + {:ok, view, _html} = live(conn, ~p"/admin/users") + + render_click(view, "update-selected-items", %{"id" => user.id}) + render_click(view, "update-selected-items", %{"id" => admin.id}) + + assert {{%Backpex.ForbiddenError{}, _stacktrace}, _mfa} = + catch_exit(render_click(view, "item-action", %{"action-key" => "user_soft_delete"})) + + assert Repo.get(User, user.id).deleted_at == nil + assert Repo.get(User, admin.id).deleted_at == nil + end + + test "are re-checked on submit when the selection is widened after the modal opened", %{ + conn: conn, + user: user, + admin: admin + } do + {:ok, view, _html} = live(conn, ~p"/admin/users") + + render_click(view, "update-selected-items", %{"id" => user.id}) + render_click(view, "item-action", %{"action-key" => "user_soft_delete"}) + + assert has_element?(view, "#resource-form") + + # The authorization state changes while the modal is open. Before the submit gate existed, + # the unauthorized item was silently filtered out and the action reported success. + render_click(view, "update-selected-items", %{"id" => admin.id}) + + assert {{%Backpex.ForbiddenError{}, _stacktrace}, _mfa} = + catch_exit( + view + |> form("#resource-form", change: %{reason: "widened after opening"}) + |> render_submit() + ) + + assert Repo.get(User, user.id).deleted_at == nil + assert Repo.get(User, admin.id).deleted_at == nil + end + end + + describe "rescue clauses in item actions" do + test "the built-in delete action reraises ForbiddenError instead of flashing it" do + product = insert(:product) + + {:ok, short_link} = + Repo.insert(%ShortLink{short_key: "rescuekey", url: "https://example.com", product_id: product.id}) + + socket = assign_socket(live_resource: DemoWeb.ShortLinkLive, item_action_key: :delete) + + assert_raise Backpex.ForbiddenError, fn -> + Backpex.ItemActions.Delete.handle(socket, [short_link], %{}) + end + + assert Repo.get_by(ShortLink, short_key: "rescuekey") + end + + test "the demo soft delete action reraises ForbiddenError instead of flashing it" do + admin = insert(:user, %{role: :admin}) + + socket = assign_socket(live_resource: DemoWeb.UserLive, item_action_key: :user_soft_delete) + + assert_raise Backpex.ForbiddenError, fn -> + DemoWeb.ItemActions.UserSoftDelete.handle(socket, [admin], %{}) + end + + assert Repo.get(User, admin.id).deleted_at == nil + end + end +end From 3febbc5c84e5762347b774052a0afd5ee8ba7f69 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:27:21 +0200 Subject: [PATCH 08/29] Document centralized authorization enforcement Add the v0.21 upgrade guide and register it in the docs extras, add an Enforcement section to the LiveResource authorization guide, and add Authorization sections to the item and resource action guides. Also fix the item-actions update_all example, which matched no signature that ever existed and had a syntactically broken rescue. --- guides/actions/item-actions.md | 65 +++++- guides/actions/resource-actions.md | 23 ++- .../live-resource-authorization.md | 64 +++++- guides/upgrading/v0.21.md | 193 ++++++++++++++++++ mix.exs | 1 + 5 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 guides/upgrading/v0.21.md diff --git a/guides/actions/item-actions.md b/guides/actions/item-actions.md index 92db78b4c..b0eaa2bcf 100644 --- a/guides/actions/item-actions.md +++ b/guides/actions/item-actions.md @@ -179,25 +179,32 @@ defmodule DemoWeb.ItemAction.SoftDelete do @impl Backpex.ItemAction def handle(socket, items, data) do - datetime = DateTime.truncate(DateTime.utc_now(), :second) + datetime = DateTime.utc_now(:second) socket = try do - {:ok, _count_} = + {:ok, _items} = Backpex.Resource.update_all( - socket.assigns, items, [set: [deleted_at: datetime, reason: data.reason]], - "deleted" + socket.assigns, + socket.assigns.live_resource, + event_name: "deleted", + authorization_action: socket.assigns.item_action_key ) - socket - |> clear_flash() - |> put_flash(:info, "Item(s) successfully deleted.") - rescue socket |> clear_flash() - |> put_flash(:error, error) + |> put_flash(:info, "Item(s) successfully deleted.") + rescue + # Never swallow the authorization gate: it must reach the router as a 403. + error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> + reraise error, __STACKTRACE__ + + error -> + socket + |> clear_flash() + |> put_flash(:error, Exception.message(error)) end {:ok, socket} @@ -209,3 +216,43 @@ The above ItemAction require users to fill out the reason field before the actio > #### Important {: .note} > If your ItemAction has form fields, you must also implement the `c:Backpex.ItemAction.confirm/1` function. + +## Authorization + +Item actions are authorized against the key they are registered under. Implement [`can?/3`](Backpex.LiveResource.html#c:can?/3) in your resource configuration module: + +```elixir +# in your resource configuration file +@impl Backpex.LiveResource +def can?(_assigns, :soft_delete, item), do: item.role != :admin +def can?(_assigns, _action, _item), do: true +``` + +Backpex enforces this for you — you do not need to check it again inside `c:Backpex.ItemAction.handle/3`. There are three things to know: + +**Enforcement is strict.** Every selected item is authorized before the confirm modal opens and again immediately before `c:Backpex.ItemAction.handle/3` runs. A selection containing a single unauthorized item raises `Backpex.ForbiddenError`; items are never silently dropped. A stale or forged item id raises `Backpex.NoResultsError`. Because a mixed selection would raise, the toolbar button is disabled whenever the selection is empty or contains an unauthorized item. + +**`handle/3` gets the full selection, and is never called with `[]`.** For an empty selection Backpex skips the action entirely. + +**Use `assigns.item_action_key` when writing.** `Backpex.Resource` mutations default to `:new` / `:edit` / `:delete`. An action registered under a custom key should authorize under that key: + +```elixir +Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, + authorization_action: socket.assigns.item_action_key +) +``` + +Backpex sets `assigns.item_action_key` immediately before calling your `handle/3`, so the action does not need to know its own registration key. + +If your action writes to a *different* resource as a side effect (nullifying a foreign key, for example), that write is not a user-initiated action on that resource — pass `authorize?: false`: + +```elixir +Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, MyAppWeb.PostLive, + event_name: "updated", + authorize?: false +) +``` + +> #### Do not swallow the gate {: .warning} +> +> A broad `rescue` around a `Backpex.Resource` call will catch `Backpex.ForbiddenError` and turn a 403 into a flash message. Reraise it, as the example above does. diff --git a/guides/actions/resource-actions.md b/guides/actions/resource-actions.md index 82c5e269e..d747beeb2 100644 --- a/guides/actions/resource-actions.md +++ b/guides/actions/resource-actions.md @@ -87,4 +87,25 @@ We validate the email address using the `validate_email/2` function provided by > #### Info {: .info} > -> Each resource action has its own route. The route is defined by the `id` of the resource action. If you use the [`live_resource/3`](Backpex.Router.html#live_resources/3) macro, the route is automatically added to the live resource. \ No newline at end of file +> Each resource action has its own route. The route is defined by the `id` of the resource action. If you use the [`live_resource/3`](Backpex.Router.html#live_resources/3) macro, the route is automatically added to the live resource. + +## Authorization + +A resource action is authorized against its `id`, with a `nil` item: + +```elixir +# in your resource configuration file +@impl Backpex.LiveResource +def can?(assigns, :invite, _item), do: assigns.current_user.role == :admin +def can?(_assigns, _action, _item), do: true +``` + +Backpex checks this twice: when the modal is opened (the button is not rendered at all when the check fails) and again when the form is submitted, so a permission revoked while the form was open cannot be used. An unauthorized submit raises `Backpex.ForbiddenError`. + +If your [`handle/2`](Backpex.ResourceAction.html#c:handle/2) calls `Backpex.Resource` functions, note that those authorize against their own defaults — `:new` for `insert/6`, `:edit` for `update/6` and `update_all/5`, `:delete` for `delete_all/4` — not against the resource action's key. Pass `authorization_action:` when the resource action's own key is the right one to check, or `authorize?: false` when the write is a system side effect rather than a user action on that resource: + +```elixir +Backpex.Resource.update_all(items, updates, socket.assigns, MyAppWeb.UserLive, + authorization_action: :invite +) +``` \ No newline at end of file diff --git a/guides/authorization/live-resource-authorization.md b/guides/authorization/live-resource-authorization.md index a3cd9542a..4f777b510 100644 --- a/guides/authorization/live-resource-authorization.md +++ b/guides/authorization/live-resource-authorization.md @@ -50,4 +50,66 @@ The `can?` callback receives the following parameters: ## Return value -The `can?` callback must return a boolean value. If the return value is `true`, the action is allowed. If the return value is `false`, the action is denied. \ No newline at end of file +The `can?` callback must return a boolean value. If the return value is `true`, the action is allowed. If the return value is `false`, the action is denied. + +## Enforcement + +Backpex enforces `can?/3` centrally, through `Backpex.Authorization`. You do not need to repeat the check in your own actions. + +There are two kinds of checks, and both run: + +- **Preflight** — decides whether a control is rendered or disabled. A user never sees a button for something they may not do. +- **Gate** — runs immediately before something happens and raises `Backpex.ForbiddenError` (403) when it fails. This is what makes a forged or stale event safe. + +### Where the gates are + +| what happens | action checked | item | +| --- | --- | --- | +| `:index` / `:show` view mounts | `:index` / `:show` | the item, for `:show` | +| `:new` / `:edit` form mounts | `:new` / `:edit` | the item, for `:edit` | +| `Backpex.Resource.insert/6` | `:new` | `nil` | +| `Backpex.Resource.update/6` | `:edit` | the item | +| `Backpex.Resource.update_all/5` | `:edit` | each item | +| `Backpex.Resource.delete_all/4` | `:delete` | each item | +| item action, before the confirm modal opens | the action key | each selected item | +| item action, before `handle/3` runs | the action key | each selected item | +| resource action, on open and on submit | the action key | `nil` | + +The `Backpex.Resource` gates run **before** the changeset is built and before `c:Backpex.Field.before_changeset/6` is called, so your own code never executes for an unauthorized request. + +### Strict semantics + +Checks over a selection are strict: a single unauthorized item raises, and nothing runs. Backpex does not silently drop items from a selection. + +A `nil` item — a stale or forged id — raises `Backpex.NoResultsError` (404) and never reaches your `can?/3`, so you do not need clauses for it. + +Because a mixed selection would raise, the bulk action button is disabled whenever the selection is empty or contains any unauthorized item. + +### Overriding the action and the escape hatch + +Every `Backpex.Resource` mutation accepts two options: + +- `:authorization_action` — authorize against this action instead of the default. Item actions should pass `socket.assigns.item_action_key`, which Backpex sets before calling `c:Backpex.ItemAction.handle/3`, so an action registered under a custom key is authorized under that key. +- `authorize?: false` — skip the check. Use this for system or cascade writes that are not a user-initiated action on the resource being written, for example nullifying a foreign key on another resource. + +```elixir +Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, MyAppWeb.PostLive, + event_name: "updated", + authorize?: false +) +``` + +### Reads are not gated in `Backpex.Resource` + +`Backpex.Resource.list/4`, `get/4` and `count/4` do not call `can?/3`. Row-level read filtering belongs in [`item_query/3`](item-query.html) — dropping rows after pagination would corrupt item counts and select-all. `:index` and `:show` are enforced when the view mounts. + +### Calling the checks yourself + +If you build your own UI on top of Backpex, use `Backpex.Authorization` rather than calling `can?/3` directly: + +```elixir +Backpex.Authorization.can?(live_resource, assigns, :edit, item) +Backpex.Authorization.can_all?(live_resource, assigns, :delete, items) +Backpex.Authorization.authorize!(live_resource, assigns, :edit, item) +Backpex.Authorization.authorize_all!(live_resource, assigns, :delete, items) +``` \ No newline at end of file diff --git a/guides/upgrading/v0.21.md b/guides/upgrading/v0.21.md new file mode 100644 index 000000000..9f39b1da4 --- /dev/null +++ b/guides/upgrading/v0.21.md @@ -0,0 +1,193 @@ +# Upgrading to v0.21 + +## Bump Your Deps + +Update Backpex to the latest version: + +```elixir +defp deps do + [ + {:backpex, "~> 0.21.0"} + ] +end +``` + +v0.21 makes authorization something Backpex enforces rather than something every +call site has to remember. `c: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 {: .error} +> +> 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`. + +## 2. `delete_all/2` → `delete_all/4` + +`delete_all` needs the assigns to authorize the deletion. + +```diff +- Backpex.Resource.delete_all(items, socket.assigns.live_resource) ++ Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource) +``` + +## 3. `update_all/4` → `update_all/5` + +`update_all` also needs the assigns, and `event_name` moved into the options. + +```diff +- 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 {: .warning} +> +> The old `update_all(items, updates, event_name, live_resource)` has the same +> arity as the new `update_all(items, updates, assigns, live_resource)`. A guard +> (`is_map(assigns)`) makes the old call fail loudly with a `FunctionClauseError` +> instead of quietly authorizing against the event name string. If you see that +> 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 `c: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 | + +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. + +### Overriding the action: `:authorization_action` + +An item action registered under a custom key should authorize against that key: + +```elixir +def handle(socket, items, _data) do + Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, + authorization_action: socket.assigns.item_action_key + ) + + {:ok, socket} +end +``` + +`assigns.item_action_key` is new in v0.21. Backpex sets it immediately before +calling `c:Backpex.ItemAction.handle/3`, so the action does not need to know +which key it was registered under. + +### Skipping the check: `authorize?: false` + +System and cascade writes are not user-initiated actions on the resource they +touch. Skip the check explicitly: + +```elixir +# 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 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` (403). Nothing runs. +* A stale or forged item id raises `Backpex.NoResultsError` (404). `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. + +Two consequences worth knowing: + +* **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. +* **`can?/3` is checked against the items loaded at render time.** Backpex does + not re-fetch between opening a modal and submitting it. The submit gate does + re-run `can?/3` against the current selection, so a selection widened after the + modal opened is caught. + +## 6. `handle_item_action/5` behavior change + +`Backpex.ItemAction.handle_item_action/5` is public. It no longer filters items; +it authorizes them and raises. If you call it yourself, expect +`Backpex.ForbiddenError` / `Backpex.NoResultsError` where you previously got a +shorter list. + +## 7. Do not let a `rescue` swallow the gate + +A broad `rescue` around a `Backpex.Resource` call will now catch +`Backpex.ForbiddenError` and turn a 403 into a flash message. Reraise it: + +```elixir +def handle(socket, items, _data) do + # ... +rescue + error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> + reraise error, __STACKTRACE__ + + error -> + # your existing error handling +end +``` + +Backpex's built-in delete action does this. Check your own actions for the same +pattern. + +## Checklist for custom actions + +- [ ] Every `delete_all/2` call updated to `delete_all/4`. +- [ ] Every `update_all/4` call updated to `update_all/5`, with `event_name:` + moved into the options. +- [ ] Item actions registered under a custom key pass + `authorization_action: socket.assigns.item_action_key`. +- [ ] Cascade or system writes pass `authorize?: false`. +- [ ] Broad `rescue` clauses 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 `[]`). diff --git a/mix.exs b/mix.exs index 8996e0144..a24736c0d 100644 --- a/mix.exs +++ b/mix.exs @@ -198,6 +198,7 @@ defmodule Backpex.MixProject do "guides/translations/translations.md", # Upgrade Guides + "guides/upgrading/v0.21.md", "guides/upgrading/v0.20.md", "guides/upgrading/v0.19.md", "guides/upgrading/v0.18.md", From b90c580795691f7684f312199c8b7336dba2d456 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:29:07 +0200 Subject: [PATCH 09/29] Satisfy credo in the new authorization tests --- test/backpex/item_action_test.exs | 36 +++++++++++-------------------- test/backpex/resource_test.exs | 11 +++++++--- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/test/backpex/item_action_test.exs b/test/backpex/item_action_test.exs index 03baef775..d73b46c30 100644 --- a/test/backpex/item_action_test.exs +++ b/test/backpex/item_action_test.exs @@ -38,59 +38,49 @@ defmodule Backpex.ItemActionTest do describe "handle_item_action/5" do test "passes the full list to handle/3 and assigns item_action_key" do items = [%{id: 1, role: :user}, %{id: 2, role: :user}] + socket = build_socket(AllowAll) assert {:after_handle, _socket} = - ItemAction.handle_item_action( - build_socket(AllowAll), - %{module: EchoAction}, - :user_soft_delete, - items, - &after_handle/1 - ) + ItemAction.handle_item_action(socket, %{module: EchoAction}, :user_soft_delete, items, &after_handle/1) assert_received {:handled, ^items, :user_soft_delete} end test "raises ForbiddenError when a single item is unauthorized and never calls handle/3" do items = [%{id: 1, role: :user}, %{id: 2, role: :admin}] + socket = build_socket(NoAdmins) assert_raise Backpex.ForbiddenError, fn -> - ItemAction.handle_item_action(build_socket(NoAdmins), %{module: EchoAction}, :delete, items, &after_handle/1) + ItemAction.handle_item_action(socket, %{module: EchoAction}, :delete, items, &after_handle/1) end refute_received {:handled, _items, _key} end test "raises NoResultsError for a nil item and never calls handle/3" do + socket = build_socket(AllowAll) + assert_raise Backpex.NoResultsError, fn -> - ItemAction.handle_item_action(build_socket(AllowAll), %{module: EchoAction}, :delete, [nil], &after_handle/1) + ItemAction.handle_item_action(socket, %{module: EchoAction}, :delete, [nil], &after_handle/1) end refute_received {:handled, _items, _key} end test "never calls handle/3 for an empty selection but still runs after_handle" do + socket = build_socket(AllowAll) + assert {:after_handle, _socket} = - ItemAction.handle_item_action( - build_socket(AllowAll), - %{module: EchoAction}, - :delete, - [], - &after_handle/1 - ) + ItemAction.handle_item_action(socket, %{module: EchoAction}, :delete, [], &after_handle/1) refute_received {:handled, _items, _key} end test "raises ArgumentError on an unexpected return value" do + socket = build_socket(AllowAll) + assert_raise ArgumentError, ~r/Invalid return value/, fn -> - ItemAction.handle_item_action( - build_socket(AllowAll), - %{module: BadReturnAction}, - :delete, - [%{id: 1}], - &after_handle/1 - ) + ItemAction.handle_item_action(socket, %{module: BadReturnAction}, :delete, [%{id: 1}], &after_handle/1) end end end diff --git a/test/backpex/resource_test.exs b/test/backpex/resource_test.exs index 46a91b63c..b93a95a6e 100644 --- a/test/backpex/resource_test.exs +++ b/test/backpex/resource_test.exs @@ -233,9 +233,12 @@ defmodule Backpex.ResourceTest do end test "does not answer the pre-0.21 delete_all/2 signature" do - # `apply/3` keeps the compiler's type checker out of it — the point is the runtime behavior. + # The arguments go through a variable so the compiler's type checker does not flag the + # intentionally wrong call — what is under test is the runtime behavior. + args = [[%{id: 1}], AllowAll] + assert_raise UndefinedFunctionError, fn -> - apply(Resource, :delete_all, [[%{id: 1}], AllowAll]) + apply(Resource, :delete_all, args) end end end @@ -303,8 +306,10 @@ defmodule Backpex.ResourceTest do # `update_all(items, updates, "deleted", MyLive)` has the same arity as the new # `update_all(items, updates, assigns, live_resource)`. The `is_map(assigns)` guard makes the # old call fail loudly instead of silently authorizing against a string. + args = [[%{id: 1}], [set: [x: 1]], "deleted", AllowAll] + assert_raise FunctionClauseError, fn -> - apply(Resource, :update_all, [[%{id: 1}], [set: [x: 1]], "deleted", AllowAll]) + apply(Resource, :update_all, args) end end end From a85f6d8f2a69ed7891cf8b8f43289b74e602b4d1 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 16:30:31 +0200 Subject: [PATCH 10/29] Format demo authorization test --- .../live/authorization_enforcement_test.exs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/demo/test/demo_web/live/authorization_enforcement_test.exs b/demo/test/demo_web/live/authorization_enforcement_test.exs index ace006007..8f4f74f65 100644 --- a/demo/test/demo_web/live/authorization_enforcement_test.exs +++ b/demo/test/demo_web/live/authorization_enforcement_test.exs @@ -60,7 +60,9 @@ defmodule DemoWeb.Live.AuthorizationEnforcementTest do {:ok, view, _html} = live(conn, ~p"/admin/users") assert {{%Backpex.ForbiddenError{}, _stacktrace}, _mfa} = - catch_exit(render_click(view, "item-action", %{"action-key" => "user_soft_delete", "item-id" => admin.id})) + catch_exit( + render_click(view, "item-action", %{"action-key" => "user_soft_delete", "item-id" => admin.id}) + ) assert Repo.get(User, admin.id).deleted_at == nil end @@ -164,11 +166,10 @@ defmodule DemoWeb.Live.AuthorizationEnforcementTest do render_click(view, "update-selected-items", %{"id" => admin.id}) assert {{%Backpex.ForbiddenError{}, _stacktrace}, _mfa} = - catch_exit( - view - |> form("#resource-form", change: %{reason: "widened after opening"}) - |> render_submit() - ) + view + |> form("#resource-form", change: %{reason: "widened after opening"}) + |> render_submit() + |> catch_exit() assert Repo.get(User, user.id).deleted_at == nil assert Repo.get(User, admin.id).deleted_at == nil From 044325238615d57ec3c5dcc058416733c24b8691 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:29:02 +0200 Subject: [PATCH 11/29] Share the fake LiveResources used by the authorization tests Move the AllowAll / DenyAll / NoAdmins / KeyAware / OnlyCustomKey / Recording stubs and the stub adapter into test/support so the authorization, item action and resource tests alias one definition instead of redefining them two or three times. --- test/backpex/authorization_test.exs | 34 ++------- test/backpex/item_action_test.exs | 13 +--- test/backpex/resource_test.exs | 94 ++--------------------- test/support/live_resources.ex | 114 ++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 125 deletions(-) create mode 100644 test/support/live_resources.ex diff --git a/test/backpex/authorization_test.exs b/test/backpex/authorization_test.exs index b42889e10..d98f84190 100644 --- a/test/backpex/authorization_test.exs +++ b/test/backpex/authorization_test.exs @@ -2,32 +2,10 @@ defmodule Backpex.AuthorizationTest do use ExUnit.Case, async: true alias Backpex.Authorization - - defmodule AllowAll do - @moduledoc false - def can?(_assigns, _action, _item), do: true - end - - defmodule DenyAll do - @moduledoc false - def can?(_assigns, _action, _item), do: false - end - - defmodule KeyAware do - @moduledoc false - def can?(_assigns, :delete, %{role: :admin} = _item), do: false - def can?(_assigns, :delete, _item), do: true - def can?(_assigns, :new, nil), do: false - def can?(_assigns, _action, _item), do: true - end - - defmodule Recorder do - @moduledoc false - def can?(assigns, action, item) do - send(assigns.test_pid, {:can?, action, item}) - true - end - end + alias Backpex.Test.LiveResources.AllowAll + alias Backpex.Test.LiveResources.DenyAll + alias Backpex.Test.LiveResources.KeyAware + alias Backpex.Test.LiveResources.Recording @assigns %{live_resource: AllowAll} @@ -40,7 +18,7 @@ defmodule Backpex.AuthorizationTest do test "passes action and item through untouched" do item = %{id: 1} - assert Authorization.can?(Recorder, %{test_pid: self()}, :edit, item) + assert Authorization.can?(Recording, @assigns, :edit, item) assert_received {:can?, :edit, ^item} end @@ -99,7 +77,7 @@ defmodule Backpex.AuthorizationTest do test "never passes nil to the live resource" do assert_raise Backpex.NoResultsError, fn -> - Authorization.authorize_all!(Recorder, %{test_pid: self()}, :delete, [nil]) + Authorization.authorize_all!(Recording, @assigns, :delete, [nil]) end refute_received {:can?, :delete, nil} diff --git a/test/backpex/item_action_test.exs b/test/backpex/item_action_test.exs index d73b46c30..7955f91b7 100644 --- a/test/backpex/item_action_test.exs +++ b/test/backpex/item_action_test.exs @@ -2,19 +2,10 @@ defmodule Backpex.ItemActionTest do use ExUnit.Case, async: true alias Backpex.ItemAction + alias Backpex.Test.LiveResources.AllowAll + alias Backpex.Test.LiveResources.NoAdmins alias Phoenix.LiveView.Socket - defmodule AllowAll do - @moduledoc false - def can?(_assigns, _action, _item), do: true - end - - defmodule NoAdmins do - @moduledoc false - def can?(_assigns, _action, %{role: :admin} = _item), do: false - def can?(_assigns, _action, _item), do: true - end - defmodule EchoAction do @moduledoc false def handle(socket, items, _data) do diff --git a/test/backpex/resource_test.exs b/test/backpex/resource_test.exs index b93a95a6e..9cd9013a4 100644 --- a/test/backpex/resource_test.exs +++ b/test/backpex/resource_test.exs @@ -2,94 +2,16 @@ defmodule Backpex.ResourceTest do use ExUnit.Case, async: true alias Backpex.Resource - alias Backpex.ResourceTest.PubSub - - @pubsub_server PubSub - @topic "backpex_resource_test" - - defmodule StubAdapter do - @moduledoc false - - # Every call reports back to the test process. Tests use `refute_received/1` to prove that a - # denied mutation never reached the data layer. - - def change(item, attrs, _fields, _assigns, _live_resource, opts) do - send(self(), {:adapter, :change, opts}) - - {:changeset, item, attrs} - end - - def insert({:changeset, item, attrs}, _live_resource) do - send(self(), {:adapter, :insert, item, attrs}) - - {:ok, item} - end - - def update({:changeset, item, attrs}, _live_resource) do - send(self(), {:adapter, :update, item, attrs}) - - {:ok, item} - end - - def delete_all(items, _live_resource) do - send(self(), {:adapter, :delete_all, items}) - - {:ok, items} - end - - def update_all(items, updates, _live_resource) do - send(self(), {:adapter, :update_all, items, updates}) - - {length(items), nil} - end - end - - defmodule AllowAll do - @moduledoc false - def config(:adapter), do: Backpex.ResourceTest.StubAdapter - def can?(_assigns, _action, _item), do: true - def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] - end - - defmodule DenyAll do - @moduledoc false - def config(:adapter), do: Backpex.ResourceTest.StubAdapter - def can?(_assigns, _action, _item), do: false - def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] - end - - defmodule Recording do - @moduledoc false - def config(:adapter), do: Backpex.ResourceTest.StubAdapter - - def can?(_assigns, action, item) do - send(self(), {:can?, action, item}) - - true - end - - def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] - end - - defmodule OnlyCustomKey do - @moduledoc false - def config(:adapter), do: Backpex.ResourceTest.StubAdapter - def can?(_assigns, :custom_key, _item), do: true - def can?(_assigns, _action, _item), do: false - def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] - end - - defmodule NoAdmins do - @moduledoc false - def config(:adapter), do: Backpex.ResourceTest.StubAdapter - def can?(_assigns, _action, %{role: :admin} = _item), do: false - def can?(_assigns, _action, _item), do: true - def pubsub, do: [server: PubSub, topic: "backpex_resource_test"] - end + alias Backpex.Test.LiveResources + alias Backpex.Test.LiveResources.AllowAll + alias Backpex.Test.LiveResources.DenyAll + alias Backpex.Test.LiveResources.NoAdmins + alias Backpex.Test.LiveResources.OnlyCustomKey + alias Backpex.Test.LiveResources.Recording setup do - start_supervised!({Phoenix.PubSub, name: @pubsub_server}) - :ok = Phoenix.PubSub.subscribe(@pubsub_server, @topic) + start_supervised!({Phoenix.PubSub, name: LiveResources.pubsub_server()}) + :ok = Phoenix.PubSub.subscribe(LiveResources.pubsub_server(), LiveResources.pubsub_topic()) %{assigns: %{some: :assign}, item: %{id: 1}, fields: []} end diff --git a/test/support/live_resources.ex b/test/support/live_resources.ex new file mode 100644 index 000000000..c7ec04996 --- /dev/null +++ b/test/support/live_resources.ex @@ -0,0 +1,114 @@ +defmodule Backpex.Test.StubAdapter do + @moduledoc """ + A `Backpex.Adapter` stand-in that performs no I/O. + + Every call reports back to the calling process as `{:adapter, function_name, ...}`, so a test can + use `refute_received/1` to prove that a denied mutation never reached the data layer. + """ + + def change(item, attrs, _fields, _assigns, _live_resource, opts) do + send(self(), {:adapter, :change, opts}) + + {:changeset, item, attrs} + end + + def insert({:changeset, item, attrs}, _live_resource) do + send(self(), {:adapter, :insert, item, attrs}) + + {:ok, item} + end + + def update({:changeset, item, attrs}, _live_resource) do + send(self(), {:adapter, :update, item, attrs}) + + {:ok, item} + end + + def delete_all(items, _live_resource) do + send(self(), {:adapter, :delete_all, items}) + + {:ok, items} + end + + def update_all(items, updates, _live_resource) do + send(self(), {:adapter, :update_all, items, updates}) + + {length(items), nil} + end +end + +defmodule Backpex.Test.LiveResources do + @moduledoc """ + Fake LiveResource modules with hand-written `can?/3` clauses, shared by the authorization, item + action and resource tests. + + Each module implements only what `Backpex.Authorization` and `Backpex.Resource` touch: `can?/3`, + `config/1` for the adapter and `pubsub/0`. Tests that need the PubSub broadcasts have to start + `{Phoenix.PubSub, name: #{inspect(__MODULE__)}.pubsub_server()}` themselves. + """ + + alias Backpex.Test.PubSub + alias Backpex.Test.StubAdapter + + @pubsub_server PubSub + @pubsub_topic "backpex_test" + + @doc "Name of the PubSub server every fake LiveResource in this module broadcasts on." + def pubsub_server, do: @pubsub_server + + @doc "Topic every fake LiveResource in this module broadcasts on." + def pubsub_topic, do: @pubsub_topic + + defmodule AllowAll do + @moduledoc "Authorizes everything." + def config(:adapter), do: StubAdapter + def can?(_assigns, _action, _item), do: true + def pubsub, do: [server: PubSub, topic: "backpex_test"] + end + + defmodule DenyAll do + @moduledoc "Denies everything." + def config(:adapter), do: StubAdapter + def can?(_assigns, _action, _item), do: false + def pubsub, do: [server: PubSub, topic: "backpex_test"] + end + + defmodule NoAdmins do + @moduledoc "Denies every action on an item with `role: :admin`, allows everything else." + def config(:adapter), do: StubAdapter + def can?(_assigns, _action, %{role: :admin} = _item), do: false + def can?(_assigns, _action, _item), do: true + def pubsub, do: [server: PubSub, topic: "backpex_test"] + end + + defmodule KeyAware do + @moduledoc "Answers differently per action key, including a `nil` item." + def config(:adapter), do: StubAdapter + def can?(_assigns, :delete, %{role: :admin} = _item), do: false + def can?(_assigns, :delete, _item), do: true + def can?(_assigns, :new, nil), do: false + def can?(_assigns, _action, _item), do: true + def pubsub, do: [server: PubSub, topic: "backpex_test"] + end + + defmodule OnlyCustomKey do + @moduledoc "Authorizes `:custom_key` only, so tests can tell which action key was checked." + def config(:adapter), do: StubAdapter + def can?(_assigns, :custom_key, _item), do: true + def can?(_assigns, _action, _item), do: false + def pubsub, do: [server: PubSub, topic: "backpex_test"] + end + + defmodule Recording do + @moduledoc "Authorizes everything and reports each check as `{:can?, action, item}`." + def config(:adapter), do: StubAdapter + + def can?(_assigns, action, item) do + send(self(), {:can?, action, item}) + + true + end + + def pubsub, do: [server: PubSub, topic: "backpex_test"] + end +end From 26c71ee1fa6891255756e15d739bc56d6de1ee58 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:29:26 +0200 Subject: [PATCH 12/29] Reject a nil :authorization_action in Backpex.Resource Keyword.pop/3 returns an explicitly passed nil rather than the default, and nil is an atom, so authorization_action: nil reached can?(assigns, nil, item) where a permissive catch-all clause silently authorized the mutation. Validate the option like :authorize? already is. --- .../ich-m-chte-dass-die-luminous-knuth.md | 142 ++++++++++++++++++ lib/backpex/resource.ex | 13 +- test/backpex/resource_test.exs | 30 ++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 .claude/plans/ich-m-chte-dass-die-luminous-knuth.md diff --git a/.claude/plans/ich-m-chte-dass-die-luminous-knuth.md b/.claude/plans/ich-m-chte-dass-die-luminous-knuth.md new file mode 100644 index 000000000..026997749 --- /dev/null +++ b/.claude/plans/ich-m-chte-dass-die-luminous-knuth.md @@ -0,0 +1,142 @@ +# Centralized Authorization Enforcement via Backpex.Authorization + Action Gates + +> Incorporates the review from MR !731 (Flo, 2026-08-18): new `Backpex.Authorization` module, gate **before** `change/6`, option names `authorization_action:`/`authorize?:`, upgrade guide `v0.21.md`, hardened item-ID entry points, key resolver without `String.to_existing_atom`, updated line references. + +## Context + +Authorization (`can?/3`) is currently checked exclusively by the **callers** in the LiveView layer — partly only at mount time (`form.ex`), partly not at all at execution time. Concrete gaps: + +- `Backpex.Resource` (lib/backpex/resource.ex) contains **not a single** `can?` call; `insert/update/delete_all/update_all` blindly trust the caller. +- Item actions silently filter out unauthorized items (`item_action.ex:271-275`, `form_component.ex:389`) — `handle/3` runs even with an empty list and reports success. +- Resource actions are only checked when the modal opens (`index.ex:498`), **not on submit** (`form_component.ex:307-356`). +- `:new`/`:edit` saves are only checked at mount, not at `Resource.insert/update` time. +- On modal submit, the action key is read from the **DOM parameter** (`form_component.ex:182/199`) — the `can?` check runs against the client-supplied key while the server-side module executes → **actively bypassable, classify as a security fix** (not just "forgettable"). +- `handle_event("update-selected-items", ...)` (`index.ex:200`) accepts forged IDs → `nil` lands in `selected_items` and reaches user `can?` during render. + +**Goal:** Authorization is enforced centrally and can no longer be forgotten or bypassed. + +**Decisions made (user + review):** +1. Enforcement in `Backpex.Resource` (all mutations) **plus** hard gates directly before `action.module.handle` (actions run arbitrary code that doesn't necessarily write through `Backpex.Resource`). +2. **Strict**: a single unauthorized item in a selection → `Backpex.ForbiddenError` (403). No more silent filtering. (= Ash `access_type :strict`; right for an admin UI, the button gets disabled anyway.) +3. Breaking changes to `Backpex.Resource` are OK, documented in an upgrade guide (the module is marked "under heavy development", Backpex is pre-1.0). +4. **Review:** checks live in a dedicated `Backpex.Authorization` module — `can?`-style functions are for the UI (preflight), `authorize!`-style functions are the execution gate. Attachment point for a future authorizer behaviour. +5. **Review:** the gate runs **before `Resource.change/6`** — `Resource` is not a context module, but in a default Phoenix app authorization happens in the context function that wraps both changeset and DB access. `persist_item` runs user code (changeset, `before_changeset/6`) before the adapter, so gating "before the adapter call" is too late. `Resource.change/6` itself stays ungated (live validation!). + +## Design cornerstones + +- **Authorization-action mechanism**: each mutation authorizes against a default action (`insert`→`:new`, `update`→`:edit`, `delete_all`→`:delete`, `update_all`→`:edit`), overridable via `opts[:authorization_action]` (review: more explicit than `action_key`, which mentally collides with `opts[:action]` in `change/6`). Item-action code receives the correct key through a new assign `assigns.item_action_key`, set immediately before `action.module.handle/3`. +- **Escape hatch**: `authorize?: false` option on all four mutations (for system/cascade writes, e.g. the demo's post nullification across another resource). Explicit and greppable. Simple `is_boolean` validation. +- **`can?` conventions**: `insert` checks `can?(assigns, action, nil)` (consistent with all existing `:new` checks; prevents a silent-allow hole for user clauses matching `nil`). `update` checks with the item; `delete_all`/`update_all` per item. For item actions, **no** key-level check with `nil` is introduced (would crash user pattern matches expecting a struct). +- **Failure semantics**: unauthorized → `Backpex.ForbiddenError`; `nil` item (stale/forged id) → `Backpex.NoResultsError` (404 semantics — anti-enumeration; `nil` never reaches user `can?`); unknown action key → `NoResultsError`; empty selection → no-op (`handle/3` is never called with `[]`). +- **Key resolution without `String.to_existing_atom`** (review): today a forged key raises `ArgumentError` before any `NoResultsError`. Resolve client-supplied keys by comparing the binary against the registered action keys (`item_actions`/`resource_actions`); unknown → `NoResultsError`. Applies to `index.ex:294`, `show.ex:77`, `index.ex:493-495`. +- Reads (`list/get/count`) remain ungated (`:index`/`:show` stay enforced in the view layer; filtering after pagination would corrupt counts/select-all) — documented. The adapter behaviour stays untouched (adapter callbacks have no assigns). + +## Steps + +### 1. New module `Backpex.Authorization` (`lib/backpex/authorization.ex`) +Public API (under the hood everything calls `live_resource.can?(assigns, action, item)`): +```elixir +Backpex.Authorization.can?(live_resource, assigns, action, item) # preflight (UI) +Backpex.Authorization.can_all?(live_resource, assigns, action, items) # preflight, all items +Backpex.Authorization.authorize!(live_resource, assigns, action, item) # gate: ForbiddenError +Backpex.Authorization.authorize_all!(live_resource, assigns, action, items) # gate per item; nil item → NoResultsError +``` +Refactor the existing raise sites to use it (`index.ex:498`, `form.ex:96-106`, `show.ex:64`; `maybe_handle_item_action` follows in Step 4). Extend the `c:can?/3` doc (live_resource.ex) to describe enforcement. This module is the future attachment point for an authorizer behaviour (Ash-style `Authorizer` split — explicitly **not now**). + +### 2. New `Backpex.Resource` API (`lib/backpex/resource.ex`) +- `insert/6` and `update/6` (both `opts \\ []`): **gate at the top of `insert`/`update`**, before `change/6`/`before_changeset` run (review #2 — not inside `persist_item`): + - `{authorize?, opts} = Keyword.pop(opts, :authorize?, true)` (validate `is_boolean`), `{authorization_action, opts} = Keyword.pop(opts, :authorization_action, default)` (`insert`→`:new`, `update`→`:edit`) + - when `authorize?`: `Backpex.Authorization.authorize!(live_resource, assigns, authorization_action, can_item)` with `can_item = nil` for insert, otherwise `item`. + - `:authorization_action`/`:authorize?` are popped before the remaining opts reach `change/6`. + - `Resource.change/6` itself stays ungated (live validation). +- **Breaking:** `delete_all(items, live_resource)` → `delete_all(items, assigns, live_resource, opts \\ [])` with guard `is_list(items) and is_map(assigns) and is_atom(live_resource)`; default action `:delete`; `authorize_all!` (nil item → `NoResultsError`). +- **Breaking:** `update_all(items, updates, event_name \\ "updated", live_resource)` → `update_all(items, updates, assigns, live_resource, opts \\ [])`; `event_name` moves into opts; same guard. **The `is_map(assigns)` guard is load-bearing**: the old 4-arity call (`update_all(items, updates, "deleted", Mod)`) collides with the new arity and must fail loudly with a `FunctionClauseError` instead of silently "authorizing" against a string. +- An empty item list passes vacuously (nothing to authorize; the view gates no-op beforehand). +- Update moduledoc/`@doc`s (replace the `TODO: docs`): `:authorization_action`, `:authorize?`, `:event_name`, note that reads are not gated. No `iex>` doctests (test/doc_test.exs:7 runs doctests for this module). + +### 3. Strict gate in `Backpex.ItemAction` (`lib/backpex/item_actions/item_action.ex:265-289`) +- Delegate to `Backpex.Authorization.authorize_all!/4` (per item; `nil` → `NoResultsError`). +- `handle_item_action/5`: replace the filter with the gate (hard, directly before `handle`); when `items == []` only `after_handle.(socket)` (never `handle(socket, [], _)`); otherwise `assign(socket, :item_action_key, key)` and call `handle` with the **full** list. Adjust the `@doc` (currently advertises filtering) and document `assigns.item_action_key`. + +### 4. View-layer gates + entry-point hardening +- **`lib/backpex/live_resource/index.ex`**: + - `handle_event("item-action", %{"item-id" => ...})` (:132): `find_item_by_primary_value(...) || raise(Backpex.NoResultsError)` before the item enters the selection. + - `handle_event("update-selected-items", ...)` (:200) (review #3): validate the ID — forged/stale ID must not put `nil` into `selected_items` (would reach user `can?` during render). Validate **all** item-ID entry points, not just `item-action`. + - `maybe_handle_item_action/2` (:293): resolve the key against registered item actions (no `String.to_existing_atom`); unknown key → `NoResultsError`; then `Backpex.Authorization.authorize_all!(live_resource, socket.assigns, key, items)` **before** the `has_confirm_modal?` branch (the modal never opens for an unauthorized selection; the Step 3 gate stays as defense in depth). +- **`lib/backpex/live_resource/show.ex`** `maybe_handle_item_action/2` (:76-86): same resolver + `authorize_all!` with `[item]`. +- **`lib/backpex/live_components/form_component.ex`**: + - `handle_event("save", %{"action-key" => ...})` (:182, :199): ignore the DOM param, take the key server-side from `socket.assigns.action_to_confirm.key` (set at index.ex:308 / show.ex:96); remove `phx-value-action-key`. Classification: hardening — no bypass remains once central enforcement is in place, but today this is the active bypass (see Context). + - `handle_form_item_action/3` (:358-397): replace the filter (:389); `authorize_all!` **before** changeset validation; on empty selection no-op (clear selected_items, `push_navigate(to: return_to)`); otherwise call `handle` with `assign(socket, :item_action_key, action_key)` and the full list. + - `handle_save(socket, :resource_action, ...)` (:307): at the top, `Backpex.Authorization.authorize!(live_resource, assigns, assigns.resource_action_id, nil)` — closes the submit window (the gate at index.ex:498 stays). `resource_action_id` verifiably reaches the component via `Map.drop(assigns, ...)` in resource_index.html.heex:15. + +### 5. Index-editable cleanup (`lib/backpex/field.ex:424-453`) +Remove the manual check at :427-429 — `Resource.update/6` now enforces `:edit` with identical assigns/item at the same effective point (avoids evaluating user `can?` twice per inline edit). Behavior (raise on forged `update-field` events) unchanged. + +### 6. Built-in Delete action (`lib/backpex/item_actions/delete.ex:44-61`) +- `Resource.delete_all(items, socket.assigns, live_resource, authorization_action: Map.get(socket.assigns, :item_action_key, :delete))` — correct even when registered under a custom key. +- In the existing `rescue`: `error in [Backpex.ForbiddenError] -> reraise error, __STACKTRACE__` as the first clause — the blanket rescue must not turn the defense-in-depth check into a flash message. + +### 7. UI alignment (`lib/backpex/html/resource.ex:975`, call site :898) +`action_disabled?/3`: disable the toolbar button when the selection is empty **or** any item is unauthorized. **Handle the `Enum.all?([]) == true` pitfall explicitly** (review): `items == [] or not Enum.all?(items, &can?/…)` — matching the strict semantics (a mixed selection would now raise). Use `Backpex.Authorization.can_all?/4` for the item check. + +### 8. Demo migration (`demo/lib/demo_web/item_actions/user_soft_delete.ex`) +- :71 → `Backpex.Resource.update_all(items, updates, socket.assigns, socket.assigns.live_resource, authorization_action: socket.assigns.item_action_key, event_name: "deleted")` +- :77 (cross-resource cascade onto `DemoWeb.PostLive` — the canonical escape-hatch case) → `..., socket.assigns, DemoWeb.PostLive, event_name: "updated", authorize?: false)` +- Demo soft-delete `rescue`: same `reraise ForbiddenError` treatment as Step 6. +- Demo `can?` audit (verified): `film_review_live` removes the delete action entirely; `short_link_live` denies `:delete` with the action still registered (forged events now raise — desired); `user_live` denies `:user_soft_delete` for admins and **relied on silent filtering** for select-all → with Step 7 the button is now disabled for mixed selections, forged events raise. + +### 9. Docs +- **New upgrade guide `guides/upgrading/v0.21.md`** (review #1: v0.20.0 is released, `v0.20.md` already exists; mix.exs is at 0.20.0), register in `mix.exs` `extras()`. Contents: + - **Declare the DOM `action-key` fix as a security fix**: today the `can?` check uses the client-supplied key while the server-side module executes — actively bypassable, not just "forgettable". + - Both signature changes with before/after; the `FunctionClauseError` note for the old `update_all/4` arity. + - Central enforcement + default actions + `authorization_action:`/`authorize?: false`/`item_action_key`; `insert` checks with `nil`. + - Strict item-action behavior (raise instead of filter, button disable, `NoResultsError` for unknown items/keys, DOM `action-key` ignored), snapshot staleness, double-click → `NoResultsError`. + - Checklist for custom actions; note on the `handle_item_action/5` behavior change (public function). +- `guides/authorization/live-resource-authorization.md`: new "Enforcement" section (where checks run, strict semantics, escape hatch, reads ungated, `Backpex.Authorization` API). +- `guides/actions/item-actions.md`: **fix the stale `update_all` example at ~:187** (matches no signature that ever existed; its `rescue` is also syntactically broken and would swallow `ForbiddenError`) + add an "Authorization" section. +- `guides/actions/resource-actions.md`: add an "Authorization" note (gate at open + submit; `Resource` calls inside `handle/2` default to `:new`/`:edit` → pass `authorization_action:`/`authorize?: false` where needed). + +### 10. Tests +**Library (host: `mix test`; no DB access):** +- New `test/backpex/authorization_test.exs`: unit tests for `can?/can_all?/authorize!/authorize_all!` (fake live resources `AllowAll`/`DenyAll`/`KeyAware`; `nil` item in `authorize_all!` → `NoResultsError`). +- New `test/backpex/resource_test.exs`: hand-rolled fixtures (no `use Backpex.LiveResource` needed — `Resource` only calls `config(:adapter)`, `can?/3`, `pubsub/0`): `StubAdapter` (sends `{:adapter, fun}` to the test pid → `refute_received` proves "adapter never touched on denial"), `start_supervised({Phoenix.PubSub, ...})` for broadcast assertions. Cases: **denial raises before `change/6`/`adapter.change`** (review); success + broadcast; `authorization_action:` override; `insert` passes `nil` to `can?`; `authorize?: false`; **`:authorization_action`/`:authorize?` never reach `change/6`** (review); `delete_all` with one forbidden item raises entirely; `nil` in the list → `NoResultsError`; `[]` passes; **old `update_all` arity → `assert_raise FunctionClauseError`**. +- New `test/backpex/item_action_test.exs`: socket built directly, fixture action sends `{:handled, items}`: all authorized → full list + `item_action_key` set; one forbidden → `ForbiddenError` + `refute_received`; `nil` → `NoResultsError`; `[]` → `handle` not called, `after_handle` is. + +**Demo integration (`docker compose exec -T app mix test`):** +- Forged `item-action` event (row, no form) on short links (`can?(:delete) == false`): `catch_exit` with `%Backpex.ForbiddenError{}`, record still exists. +- Forged event for an admin user with `user_soft_delete` (modal path): `ForbiddenError` at the modal-open gate, `deleted_at` stays `nil`. +- Mixed selection (admin + non-admin): (a) toolbar button renders `disabled`, (b) forged bulk event → `ForbiddenError`, nobody soft-deleted. +- Forged event with a nonexistent `item-id` → exit with `%Backpex.NoResultsError{}`. +- **Forged `update-selected-items` IDs** (review): no `nil` in `selected_items`, no crash in user `can?`. +- **Forged/unknown action key** (review): `NoResultsError`, no `ArgumentError` from `String.to_existing_atom`. +- **Permission revoked between modal open and submit** (review): submit gate raises `ForbiddenError`. +- **Rescues don't swallow `ForbiddenError`** (review): built-in Delete + demo soft-delete. +- Regression: existing `soft_delete_item_action_live_test.exs`, film-review/short-link tests must pass after Step 8 (they now exercise the new signature, `item_action_key`, and `authorize?: false` end-to-end). + +## Sequencing (per review) + +1. `Backpex.Authorization` + unit tests. +2. `Resource` enforcement (gate before `change/6`) + library tests → library compiles, tested standalone. +3. Key resolver + hardening of all item-ID entry points (Step 4 entry points). +4. Action gates + server-side dispatch (Steps 3, 4, 6). +5. UI alignment (Step 7), remove duplicate check (Step 5). +6. Demo migration (Step 8) → demo suite in docker. +7. Demo integration tests (Step 10). +8. Guides + `v0.21.md` (Step 9). +9. `mix format` + `mix lint` (host), `docker compose exec -T app mix test` + `docker compose exec -T app bun run lint` (demo). + +## Risks / edge cases + +- **`insert` must check with `nil`**, otherwise a silent-allow hole for user clauses like `can?(_, :new, nil)` with a permissive catch-all. +- **Do not omit the `is_map(assigns)` guard on `update_all`** — the most dangerous collision (same arity, different argument meaning). +- **Gate placement matters** (review): `before_changeset/6` and the changeset are user code — authorizing only "before the adapter" would run user code for unauthorized requests. Gate at the top of `insert`/`update`. +- `Delete.handle`'s `rescue` would swallow `ForbiddenError` without the `reraise`; the custom-action example in item-actions.md and the demo soft-delete have the same pattern → fix all three. +- Snapshot staleness: `can?` checks against items loaded at render time; no re-fetch between modal open and submit (same as today; document + test the revocation window at the submit gate). +- Double-click after deletion: now `NoResultsError` (LV crash + reconnect) instead of a "0 items deleted" flash — deliberate trade-off, mention in the upgrade guide. +- `Enum.all?([]) == true`: empty selection must disable the button explicitly (Step 7). + +## Deliberately deferred (review "Later, not now") + +- Pull `can?/3` behind a small authorizer behaviour (= Ash's `Authorizer`/`Policy.Authorizer` split) — `Backpex.Authorization` is the attachment point. +- Explicit actor instead of full `assigns`. +- Optional strict mode (deny-by-default). No policy DSL, no query filtering, no `:maybe`. diff --git a/lib/backpex/resource.ex b/lib/backpex/resource.ex index a6d322633..fd8283468 100644 --- a/lib/backpex/resource.ex +++ b/lib/backpex/resource.ex @@ -24,8 +24,9 @@ defmodule Backpex.Resource do Two options control this on every mutation: - * `:authorization_action` (atom) — authorize against this action instead of the default. Item - actions should pass `assigns.item_action_key` so a custom registration key is honored. + * `:authorization_action` (atom) — authorize against this action instead of the default. It must + be a non-nil atom; anything else raises `ArgumentError` rather than being handed to a + permissive catch-all `can?/3` clause. * `:authorize?` (boolean, default `true`) — set to `false` to skip the check entirely. This is the escape hatch for system or cascade writes that are not a user-initiated action on that resource, for example nullifying foreign keys on another resource. @@ -254,6 +255,14 @@ defmodule Backpex.Resource do raise ArgumentError, "expected :authorize? to be a boolean, got: #{inspect(authorize?)}" end + # `Keyword.pop/3` returns an explicitly passed `nil` rather than the default, and `nil` is an + # atom — so an unvalidated `authorization_action: nil` would reach `can?(assigns, nil, item)`, + # where a permissive catch-all clause silently authorizes the mutation. Refuse it here. + if is_nil(authorization_action) or not is_atom(authorization_action) do + raise ArgumentError, + "expected :authorization_action to be a non-nil atom, got: #{inspect(authorization_action)}" + end + {authorize?, authorization_action, opts} end diff --git a/test/backpex/resource_test.exs b/test/backpex/resource_test.exs index 9cd9013a4..30687b400 100644 --- a/test/backpex/resource_test.exs +++ b/test/backpex/resource_test.exs @@ -71,6 +71,28 @@ defmodule Backpex.ResourceTest do Resource.insert(item, %{}, f, assigns, AllowAll, authorize?: :nope) end end + + test "raises when :authorization_action is nil and never reaches the adapter", %{ + assigns: assigns, + item: item, + fields: f + } do + # `Keyword.pop/3` hands back an explicitly passed `nil` instead of the default, and `nil` is + # an atom — so without validation this would check `can?(assigns, nil, item)`, which a + # permissive catch-all clause happily authorizes. + assert_raise ArgumentError, ~r/:authorization_action/, fn -> + Resource.insert(item, %{}, f, assigns, DenyAll, authorization_action: nil) + end + + refute_received {:adapter, :change, _opts} + refute_received {:adapter, :insert, _item, _attrs} + end + + test "raises when :authorization_action is not an atom", %{assigns: assigns, item: item, fields: f} do + assert_raise ArgumentError, ~r/:authorization_action/, fn -> + Resource.insert(item, %{}, f, assigns, AllowAll, authorization_action: "custom_key") + end + end end describe "update/6" do @@ -132,6 +154,14 @@ defmodule Backpex.ResourceTest do refute_received {:adapter, :delete_all, _items} end + test "raises when :authorization_action is nil and never reaches the adapter", %{assigns: assigns} do + assert_raise ArgumentError, ~r/:authorization_action/, fn -> + Resource.delete_all([%{id: 1}], assigns, DenyAll, authorization_action: nil) + end + + refute_received {:adapter, :delete_all, _items} + end + test "passes an empty list vacuously", %{assigns: assigns} do assert {:ok, []} = Resource.delete_all([], assigns, DenyAll) From 24ef4546fad91b5b7be20d1953763d1cc3c8de0d Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:30:10 +0200 Subject: [PATCH 13/29] Guard authorization entry points against a socket struct Swap is_map/1 for is_non_struct_map/1 in the new Backpex.Resource and Backpex.Authorization guards. A %Phoenix.LiveView.Socket{} passed where assigns belong must fail loudly instead of authorizing against the wrong context. --- lib/backpex/authorization.ex | 9 ++++++++- lib/backpex/resource.ex | 4 ++-- test/backpex/authorization_test.exs | 9 +++++++++ test/backpex/resource_test.exs | 13 +++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/backpex/authorization.ex b/lib/backpex/authorization.ex index 3f373a679..4ae62c988 100644 --- a/lib/backpex/authorization.ex +++ b/lib/backpex/authorization.ex @@ -30,6 +30,12 @@ defmodule Backpex.Authorization do Note that `Enum.all?/2` returns `true` for an empty list, so an empty selection passes vacuously. Callers that need "empty means not allowed" (a disabled bulk action button, for example) must handle the empty case themselves. + + ## Assigns, not the socket + + Every function here takes `assigns`, not a `%Phoenix.LiveView.Socket{}`. A guard enforces that: + passing the socket by mistake would authorize against the wrong context, and a struct must fail + loudly rather than reach a permissive `c:Backpex.LiveResource.can?/3` clause. """ @doc """ @@ -39,7 +45,8 @@ defmodule Backpex.Authorization do actions). """ @spec can?(module(), map(), atom(), map() | nil) :: boolean() - def can?(live_resource, assigns, action, item) when is_atom(live_resource) and is_map(assigns) and is_atom(action) do + def can?(live_resource, assigns, action, item) + when is_atom(live_resource) and is_non_struct_map(assigns) and is_atom(action) do live_resource.can?(assigns, action, item) end diff --git a/lib/backpex/resource.ex b/lib/backpex/resource.ex index fd8283468..2d797fdaf 100644 --- a/lib/backpex/resource.ex +++ b/lib/backpex/resource.ex @@ -116,7 +116,7 @@ defmodule Backpex.Resource do * `:authorize?` (optional, default `true`): Set to `false` to skip authorization. """ def delete_all(items, assigns, live_resource, opts \\ []) - when is_list(items) and is_map(assigns) and is_atom(live_resource) do + when is_list(items) and is_non_struct_map(assigns) and is_atom(live_resource) do _opts = authorize_items!(items, assigns, live_resource, opts, :delete) adapter = live_resource.config(:adapter) @@ -212,7 +212,7 @@ defmodule Backpex.Resource do * `:authorize?` (optional, default `true`): Set to `false` to skip authorization. """ def update_all(items, updates, assigns, live_resource, opts \\ []) - when is_list(items) and is_map(assigns) and is_atom(live_resource) do + when is_list(items) and is_non_struct_map(assigns) and is_atom(live_resource) do opts = authorize_items!(items, assigns, live_resource, opts, :edit) event_name = Keyword.get(opts, :event_name, "updated") diff --git a/test/backpex/authorization_test.exs b/test/backpex/authorization_test.exs index d98f84190..837221573 100644 --- a/test/backpex/authorization_test.exs +++ b/test/backpex/authorization_test.exs @@ -6,6 +6,7 @@ defmodule Backpex.AuthorizationTest do alias Backpex.Test.LiveResources.DenyAll alias Backpex.Test.LiveResources.KeyAware alias Backpex.Test.LiveResources.Recording + alias Phoenix.LiveView.Socket @assigns %{live_resource: AllowAll} @@ -26,6 +27,14 @@ defmodule Backpex.AuthorizationTest do refute Authorization.can?(KeyAware, @assigns, :new, nil) assert Authorization.can?(KeyAware, @assigns, :edit, nil) end + + test "refuses a socket where assigns are expected" do + # Authorizing against a `%Phoenix.LiveView.Socket{}` would answer for the wrong context. The + # guard has to reject it loudly instead of handing the struct to `can?/3`. + assert_raise FunctionClauseError, fn -> + Authorization.can?(AllowAll, %Socket{}, :new, nil) + end + end end describe "can_all?/4" do diff --git a/test/backpex/resource_test.exs b/test/backpex/resource_test.exs index 30687b400..9d7209a90 100644 --- a/test/backpex/resource_test.exs +++ b/test/backpex/resource_test.exs @@ -8,6 +8,7 @@ defmodule Backpex.ResourceTest do alias Backpex.Test.LiveResources.NoAdmins alias Backpex.Test.LiveResources.OnlyCustomKey alias Backpex.Test.LiveResources.Recording + alias Phoenix.LiveView.Socket setup do start_supervised!({Phoenix.PubSub, name: LiveResources.pubsub_server()}) @@ -193,6 +194,18 @@ defmodule Backpex.ResourceTest do apply(Resource, :delete_all, args) end end + + test "refuses a socket where assigns are expected" do + # Passing the socket instead of its assigns would authorize against the wrong context. The + # `is_non_struct_map/1` guard makes that a hard error rather than a silent mismatch. + args = [[%{id: 1}], %Socket{}, DenyAll] + + assert_raise FunctionClauseError, fn -> + apply(Resource, :delete_all, args) + end + + refute_received {:adapter, :delete_all, _items} + end end describe "update_all/5" do From cccbe90355c84a83bbfe7e52f3760e30061caf7a Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:30:42 +0200 Subject: [PATCH 14/29] Document the removed update_all/3 in the v0.21 upgrade guide The pre-0.21 head had a default event_name argument, so it defined both update_all/3 and update_all/4. The guide only covered the arity-4 form; the arity-3 form now raises UndefinedFunctionError. --- guides/upgrading/v0.21.md | 28 ++++++++++++++++++++-------- test/backpex/resource_test.exs | 9 +++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/guides/upgrading/v0.21.md b/guides/upgrading/v0.21.md index 9f39b1da4..eec794128 100644 --- a/guides/upgrading/v0.21.md +++ b/guides/upgrading/v0.21.md @@ -52,22 +52,33 @@ Client-supplied action keys are also no longer passed through + Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource) ``` -## 3. `update_all/4` → `update_all/5` +## 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: ```diff +- 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 {: .warning} > -> The old `update_all(items, updates, event_name, live_resource)` has the same -> arity as the new `update_all(items, updates, assigns, live_resource)`. A guard -> (`is_map(assigns)`) makes the old call fail loudly with a `FunctionClauseError` -> instead of quietly authorizing against the event name string. If you see that -> error, you missed a call site. +> 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_non_struct_map(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 @@ -182,8 +193,9 @@ pattern. ## Checklist for custom actions - [ ] Every `delete_all/2` call updated to `delete_all/4`. -- [ ] Every `update_all/4` call updated to `update_all/5`, with `event_name:` - moved into the options. +- [ ] 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 registered under a custom key pass `authorization_action: socket.assigns.item_action_key`. - [ ] Cascade or system writes pass `authorize?: false`. diff --git a/test/backpex/resource_test.exs b/test/backpex/resource_test.exs index 9d7209a90..0fd1c7840 100644 --- a/test/backpex/resource_test.exs +++ b/test/backpex/resource_test.exs @@ -267,6 +267,15 @@ defmodule Backpex.ResourceTest do assert {:ok, ^items} = Resource.update_all(items, [set: [x: 1]], assigns, DenyAll, authorize?: false) end + test "does not answer the pre-0.21 update_all/3 signature" do + # The old head had `event_name \\ "updated"`, so it defined an arity-3 function too. + args = [[%{id: 1}], [set: [x: 1]], AllowAll] + + assert_raise UndefinedFunctionError, fn -> + apply(Resource, :update_all, args) + end + end + test "raises FunctionClauseError for the pre-0.21 update_all/4 signature", %{assigns: _assigns} do # `update_all(items, updates, "deleted", MyLive)` has the same arity as the new # `update_all(items, updates, assigns, live_resource)`. The `is_map(assigns)` guard makes the From ab8e373d624aa60f41622d4938298037a0cd585d Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:31:22 +0200 Subject: [PATCH 15/29] Route the remaining preflight checks through Backpex.Authorization Migrate the direct live_resource.can?/3 calls in the HTML components, the show-page action buttons and the association fields. Behavior is unchanged; the point is that every check goes through one module. --- lib/backpex/fields/belongs_to.ex | 3 ++- lib/backpex/fields/has_many.ex | 3 ++- lib/backpex/fields/inline_crud.ex | 3 ++- lib/backpex/html/resource.ex | 14 +++++++++----- .../html/resource/resource_index_table.html.heex | 2 +- lib/backpex/live_resource.ex | 2 +- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/backpex/fields/belongs_to.ex b/lib/backpex/fields/belongs_to.ex index 2dba75eb1..79381d9ef 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -62,6 +62,7 @@ defmodule Backpex.Fields.BelongsTo do """ use Backpex.Field, config_schema: @config_schema import Ecto.Query + alias Backpex.Authorization alias Backpex.Router @impl Phoenix.LiveComponent @@ -238,7 +239,7 @@ defmodule Backpex.Fields.BelongsTo do live_resource = Map.get(field_options, :live_resource) link = - if live_resource && live_resource.can?(assigns, :show, value) do + if live_resource && Authorization.can?(live_resource, assigns, :show, value) do Router.get_path(socket, live_resource, params, :show, value) end diff --git a/lib/backpex/fields/has_many.ex b/lib/backpex/fields/has_many.ex index f4b815ad8..22c0e5279 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -81,6 +81,7 @@ defmodule Backpex.Fields.HasMany do import Ecto.Query alias Backpex.Adapters.Ecto, as: EctoAdapter + alias Backpex.Authorization alias Backpex.HTML.Form alias Backpex.Router @@ -456,7 +457,7 @@ defmodule Backpex.Fields.HasMany do } = assigns link = - if link_assocs and field_options.live_resource.can?(assigns, :show, item) do + if link_assocs and Authorization.can?(field_options.live_resource, assigns, :show, item) do Router.get_path(socket, Map.get(field_options, :live_resource), params, :show, item) end diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index 9f9918df5..506b3c0f9 100644 --- a/lib/backpex/fields/inline_crud.ex +++ b/lib/backpex/fields/inline_crud.ex @@ -92,6 +92,7 @@ defmodule Backpex.Fields.InlineCRUD do """ use Backpex.Field, config_schema: @config_schema + alias Backpex.Authorization alias Backpex.Router require Backpex @@ -261,7 +262,7 @@ defmodule Backpex.Fields.InlineCRUD do defp get_link(assigns, row) do live_resource = Map.get(assigns.field_options, :live_resource) - if live_resource && live_resource.can?(assigns, :show, row) do + if live_resource && Authorization.can?(live_resource, assigns, :show, row) do Router.get_path(assigns.socket, live_resource, assigns.params, :show, row) end end diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 517dee09b..269260eec 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -9,6 +9,7 @@ defmodule Backpex.HTML.Resource do import Backpex.HTML.Layout import Phoenix.LiveView.TagEngine + alias Backpex.Authorization alias Backpex.LiveResource alias Backpex.ResourceAction alias Backpex.Router @@ -105,7 +106,7 @@ defmodule Backpex.HTML.Resource do {_name, field_options} = field = Enum.find(fields, fn {field_name, _field_options} -> field_name == name end) readonly = - not live_resource.can?(assigns, :edit, item) or + not Authorization.can?(live_resource, assigns, :edit, item) or Backpex.Field.readonly?(field_options, assigns) assigns = @@ -875,7 +876,10 @@ defmodule Backpex.HTML.Resource do def resource_buttons(assigns) do ~H"""
- <.link :if={@live_resource.can?(assigns, :new, nil)} patch={Router.get_path(@socket, @live_resource, @params, :new)}> + <.link + :if={Authorization.can?(@live_resource, assigns, :new, nil)} + patch={Router.get_path(@socket, @live_resource, @params, :new)} + > @@ -951,7 +955,7 @@ defmodule Backpex.HTML.Resource do defp resource_actions(assigns, resource_actions) do Enum.filter(resource_actions, fn {key, _action} -> - assigns.live_resource.can?(assigns, key, nil) + Authorization.can?(assigns.live_resource, assigns, key, nil) end) end @@ -960,7 +964,7 @@ defmodule Backpex.HTML.Resource do resource_actions = resource_actions(assigns, assigns.resource_actions) Enum.any?(index_actions) && - (Enum.any?(resource_actions) || assigns.live_resource.can?(assigns, :new, nil)) + (Enum.any?(resource_actions) || Authorization.can?(assigns.live_resource, assigns, :new, nil)) end @doc """ @@ -1004,7 +1008,7 @@ defmodule Backpex.HTML.Resource do |> assign(:search_active?, get_in(assigns, [:query_options, :search]) not in [nil, ""]) |> assign(:filter_active?, get_in(assigns, [:query_options, :filters]) != %{}) |> assign(:title, Backpex.__({"No %{resources} found", %{resources: plural_name}}, assigns.live_resource)) - |> assign(:create_allowed, assigns.live_resource.can?(assigns, :new, nil)) + |> assign(:create_allowed, Authorization.can?(assigns.live_resource, assigns, :new, nil)) ~H"""
diff --git a/lib/backpex/html/resource/resource_index_table.html.heex b/lib/backpex/html/resource/resource_index_table.html.heex index c2679de81..d4cb637c2 100644 --- a/lib/backpex/html/resource/resource_index_table.html.heex +++ b/lib/backpex/html/resource/resource_index_table.html.heex @@ -67,7 +67,7 @@ ]}>
<%= for {key, action} <- filter_item_actions(@item_actions, :row), - @live_resource.can?(assigns, key, item) do %> + Authorization.can?(@live_resource, assigns, key, item) do %> <%= if Backpex.ItemAction.has_link?(action) do %> <.link id={"item-action-#{key}-#{LiveResource.primary_value(item, @live_resource)}"} diff --git a/lib/backpex/live_resource.ex b/lib/backpex/live_resource.ex index bab4372a9..ec921af44 100644 --- a/lib/backpex/live_resource.ex +++ b/lib/backpex/live_resource.ex @@ -485,7 +485,7 @@ defmodule Backpex.LiveResource do
<%= for {key, action} <- Backpex.HTML.Resource.filter_item_actions(@item_actions, :show), - @live_resource.can?(assigns, key, @item) do %> + Backpex.Authorization.can?(@live_resource, assigns, key, @item) do %> <%= if Backpex.ItemAction.has_link?(action) do %> <.link id={"item-action-#{key}"} From 8e160a87f5858645361c54cc8129339f562d53f2 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:31:55 +0200 Subject: [PATCH 16/29] Take fetch_action!/2 off the public LiveResource API It parses a client-supplied action key from an HTTP event, not LiveResource configuration, so it does not belong in the user-facing docs. Mark it @doc false and move the doctest into live_resource_test. --- lib/backpex/live_resource.ex | 27 ++++++++++++--------------- test/backpex/live_resource_test.exs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/lib/backpex/live_resource.ex b/lib/backpex/live_resource.ex index ec921af44..89af7f854 100644 --- a/lib/backpex/live_resource.ex +++ b/lib/backpex/live_resource.ex @@ -1053,21 +1053,18 @@ defmodule Backpex.LiveResource do defp safe_return_to?(_path), do: false - @doc """ - Resolves a client-supplied action key against a keyword list of registered actions. - - Returns `{key, action}` for the matching registration and raises `Backpex.NoResultsError` when the - key is not registered. - - The key is matched by comparing binaries rather than by `String.to_existing_atom/1`: a forged key - must produce the same 404 as an unknown one, not an `ArgumentError` that depends on which atoms - happen to exist in the running system. - - ## Examples - - iex> Backpex.LiveResource.fetch_action!([delete: %{module: Backpex.ItemActions.Delete}], "delete") - {:delete, %{module: Backpex.ItemActions.Delete}} - """ + # Resolves a client-supplied action key (from an event payload or the URL) against a keyword list + # of registered item or resource actions. + # + # Returns `{key, action}` for the matching registration and raises `Backpex.NoResultsError` when + # the key is not registered. + # + # The key is matched by comparing binaries rather than by `String.to_existing_atom/1`: a forged + # key must produce the same 404 as an unknown one, not an `ArgumentError` that depends on which + # atoms happen to exist in the running system. + # + # This parses an HTTP event, it is not LiveResource configuration API — hence `@doc false`. + @doc false def fetch_action!(actions, key) when is_list(actions) and is_binary(key) do case Enum.find(actions, fn {registered_key, _action} -> Atom.to_string(registered_key) == key end) do nil -> raise Backpex.NoResultsError diff --git a/test/backpex/live_resource_test.exs b/test/backpex/live_resource_test.exs index d4553402a..f302e249a 100644 --- a/test/backpex/live_resource_test.exs +++ b/test/backpex/live_resource_test.exs @@ -4,6 +4,7 @@ defmodule Backpex.LiveResourceTest do import Ecto.Query alias Backpex.Adapters.Ecto, as: EctoAdapter + alias Backpex.ItemActions.Delete alias Backpex.LiveResource defmodule TestPost do @@ -82,6 +83,34 @@ defmodule Backpex.LiveResourceTest do end end + describe "fetch_action!/2" do + @actions [delete: %{module: Delete}, show: %{module: Backpex.ItemActions.Show}] + + test "returns the registration for a known key" do + assert LiveResource.fetch_action!(@actions, "delete") == {:delete, %{module: Delete}} + end + + test "raises NoResultsError for an unregistered key" do + # Matching binaries rather than `String.to_existing_atom/1` keeps a forged key a 404 instead + # of an ArgumentError that depends on which atoms happen to exist in the running system. + assert_raise Backpex.NoResultsError, fn -> + LiveResource.fetch_action!(@actions, "no_such_action_key") + end + end + + test "raises NoResultsError for an existing atom that is not registered here" do + assert_raise Backpex.NoResultsError, fn -> + LiveResource.fetch_action!(@actions, "edit") + end + end + + test "raises NoResultsError for a non-binary key" do + assert_raise Backpex.NoResultsError, fn -> + LiveResource.fetch_action!(@actions, ["delete"]) + end + end + end + describe "Index.handle_event/3" do test "toggle_column is a no-op for a field the resource does not know" do # `field` arrives in a client-controlled event payload; an unknown name From d741666f2cd801a155a820f66581b30d15c52a78 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:35:56 +0200 Subject: [PATCH 17/29] Gate each item action gesture exactly once per step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal branch is gated before the dialog opens, the immediate branch is gated inside handle_item_action/5 — the authoritative execution gate. Running both meant evaluating the user's can?/3 up to three times per item for one decision, which is what the removed field.ex check was criticized for. Because Backpex guarantees handle/3 only runs after the gate covered exactly those items under that key, the built-in delete action and the demo soft delete now pass authorize?: false and drop the reraise clauses: with the check skipped, no Forbidden/NoResults can originate from those Resource calls. Also extracts the resolve-key-then-route step shared by the index and show views into Backpex.ItemAction.resolve_item_action!/3. --- .../demo_web/item_actions/user_soft_delete.ex | 10 ++- .../live/authorization_enforcement_test.exs | 35 ----------- guides/actions/item-actions.md | 41 ++++++++---- .../live-resource-authorization.md | 22 +++++-- guides/upgrading/v0.21.md | 63 +++++++++++++------ lib/backpex/item_actions/delete.ex | 11 ++-- lib/backpex/item_actions/item_action.ex | 32 +++++++++- lib/backpex/live_resource/index.ex | 19 +++--- lib/backpex/live_resource/show.ex | 20 +++--- 9 files changed, 142 insertions(+), 111 deletions(-) diff --git a/demo/lib/demo_web/item_actions/user_soft_delete.ex b/demo/lib/demo_web/item_actions/user_soft_delete.ex index cc60b345b..f178c54a6 100644 --- a/demo/lib/demo_web/item_actions/user_soft_delete.ex +++ b/demo/lib/demo_web/item_actions/user_soft_delete.ex @@ -67,10 +67,12 @@ defmodule DemoWeb.ItemActions.UserSoftDelete do try do updates = [set: [deleted_at: datetime]] + # Backpex authorized exactly these items under this action's key before calling handle/3, + # so the write does not check again. {:ok, _count} = Backpex.Resource.update_all(items, updates, socket.assigns, socket.assigns.live_resource, - authorization_action: socket.assigns.item_action_key, - event_name: "deleted" + event_name: "deleted", + authorize?: false ) # nullify the user_id in the posts owned by the users. This is a cascade write on another @@ -88,10 +90,6 @@ defmodule DemoWeb.ItemActions.UserSoftDelete do |> clear_flash() |> put_flash(:info, success_message(socket.assigns, items)) rescue - # An authorization failure must reach the router as a 403, not become a flash message. - error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> - reraise error, __STACKTRACE__ - error -> Logger.error("An error occurred while deleting the resource: #{inspect(error)}") diff --git a/demo/test/demo_web/live/authorization_enforcement_test.exs b/demo/test/demo_web/live/authorization_enforcement_test.exs index 8f4f74f65..fc3cfb77b 100644 --- a/demo/test/demo_web/live/authorization_enforcement_test.exs +++ b/demo/test/demo_web/live/authorization_enforcement_test.exs @@ -13,7 +13,6 @@ defmodule DemoWeb.Live.AuthorizationEnforcementTest do alias Demo.Repo alias Demo.ShortLink alias Demo.User - alias Phoenix.LiveView.Socket @moduletag :capture_log @@ -25,12 +24,6 @@ defmodule DemoWeb.Live.AuthorizationEnforcementTest do :ok end - defp assign_socket(assigns) do - Enum.reduce(assigns, %Socket{}, fn {key, value}, socket -> - Phoenix.Component.assign(socket, key, value) - end) - end - describe "forged item actions on a resource that denies the action" do setup do product = insert(:product) @@ -176,32 +169,4 @@ defmodule DemoWeb.Live.AuthorizationEnforcementTest do end end - describe "rescue clauses in item actions" do - test "the built-in delete action reraises ForbiddenError instead of flashing it" do - product = insert(:product) - - {:ok, short_link} = - Repo.insert(%ShortLink{short_key: "rescuekey", url: "https://example.com", product_id: product.id}) - - socket = assign_socket(live_resource: DemoWeb.ShortLinkLive, item_action_key: :delete) - - assert_raise Backpex.ForbiddenError, fn -> - Backpex.ItemActions.Delete.handle(socket, [short_link], %{}) - end - - assert Repo.get_by(ShortLink, short_key: "rescuekey") - end - - test "the demo soft delete action reraises ForbiddenError instead of flashing it" do - admin = insert(:user, %{role: :admin}) - - socket = assign_socket(live_resource: DemoWeb.UserLive, item_action_key: :user_soft_delete) - - assert_raise Backpex.ForbiddenError, fn -> - DemoWeb.ItemActions.UserSoftDelete.handle(socket, [admin], %{}) - end - - assert Repo.get(User, admin.id).deleted_at == nil - end - end end diff --git a/guides/actions/item-actions.md b/guides/actions/item-actions.md index b0eaa2bcf..f3f2ad02f 100644 --- a/guides/actions/item-actions.md +++ b/guides/actions/item-actions.md @@ -183,6 +183,8 @@ defmodule DemoWeb.ItemAction.SoftDelete do socket = try do + # Backpex already authorized exactly these items under this action's key, so this write + # does not check again. See "Authorization" below. {:ok, _items} = Backpex.Resource.update_all( items, @@ -190,17 +192,13 @@ defmodule DemoWeb.ItemAction.SoftDelete do socket.assigns, socket.assigns.live_resource, event_name: "deleted", - authorization_action: socket.assigns.item_action_key + authorize?: false ) socket |> clear_flash() |> put_flash(:info, "Item(s) successfully deleted.") rescue - # Never swallow the authorization gate: it must reach the router as a 403. - error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> - reraise error, __STACKTRACE__ - error -> socket |> clear_flash() @@ -228,23 +226,30 @@ def can?(_assigns, :soft_delete, item), do: item.role != :admin def can?(_assigns, _action, _item), do: true ``` -Backpex enforces this for you — you do not need to check it again inside `c:Backpex.ItemAction.handle/3`. There are three things to know: +Backpex enforces this for you — you do not need to check it again inside `c:Backpex.ItemAction.handle/3`. There are four things to know: + +**Enforcement is strict.** A selection containing a single unauthorized item raises `Backpex.ForbiddenError`; items are never silently dropped. A stale or forged item id raises `Backpex.NoResultsError`. Because a mixed selection would raise, the toolbar button is disabled whenever the selection is empty or contains an unauthorized item, and a row that is authorized for no bulk action at all cannot be selected. -**Enforcement is strict.** Every selected item is authorized before the confirm modal opens and again immediately before `c:Backpex.ItemAction.handle/3` runs. A selection containing a single unauthorized item raises `Backpex.ForbiddenError`; items are never silently dropped. A stale or forged item id raises `Backpex.NoResultsError`. Because a mixed selection would raise, the toolbar button is disabled whenever the selection is empty or contains an unauthorized item. +**Each gesture is authorized exactly once per step.** An action without a confirmation modal is authorized immediately before `c:Backpex.ItemAction.handle/3` runs. An action with one is authorized when the modal opens and again when it is submitted — the second check is deliberate, because a permission may be revoked, or the selection widened, while the modal is open. **`handle/3` gets the full selection, and is never called with `[]`.** For an empty selection Backpex skips the action entirely. -**Use `assigns.item_action_key` when writing.** `Backpex.Resource` mutations default to `:new` / `:edit` / `:delete`. An action registered under a custom key should authorize under that key: +**Inside `handle/3`, the items you were handed are already authorized.** Backpex guarantees the gate covered exactly those items under exactly this action's key, so a `Backpex.Resource` call that writes those same items should pass `authorize?: false`: ```elixir -Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, +Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, authorize?: false) +``` + +Anything else the action writes is *not* covered by that gate and keeps the default check. `Backpex.Resource` mutations default to `:new` / `:edit` / `:delete`; pass `:authorization_action` when a different key is the right one to check. `assigns.item_action_key` holds the key this action is registered under while `handle/3` runs, so the action does not need to hardcode it: + +```elixir +# writing other items of the same resource, under this action's key +Backpex.Resource.update_all(other_items, updates, socket.assigns, socket.assigns.live_resource, authorization_action: socket.assigns.item_action_key ) ``` -Backpex sets `assigns.item_action_key` immediately before calling your `handle/3`, so the action does not need to know its own registration key. - -If your action writes to a *different* resource as a side effect (nullifying a foreign key, for example), that write is not a user-initiated action on that resource — pass `authorize?: false`: +A cascade write to a *different* resource (nullifying a foreign key, for example) is not a user-initiated action on that resource at all — pass `authorize?: false`: ```elixir Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, MyAppWeb.PostLive, @@ -255,4 +260,14 @@ Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, M > #### Do not swallow the gate {: .warning} > -> A broad `rescue` around a `Backpex.Resource` call will catch `Backpex.ForbiddenError` and turn a 403 into a flash message. Reraise it, as the example above does. +> This only applies to `Backpex.Resource` calls that are still gated — the ones you did *not* pass `authorize?: false`. A broad `rescue` around such a call catches `Backpex.ForbiddenError` and `Backpex.NoResultsError` too, turning a denied write into a flash message that reports the action as merely failed. Reraise them: +> +> ```elixir +> rescue +> error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> +> reraise error, __STACKTRACE__ +> +> error -> +> # your own error handling +> end +> ``` diff --git a/guides/authorization/live-resource-authorization.md b/guides/authorization/live-resource-authorization.md index 4f777b510..47855ca01 100644 --- a/guides/authorization/live-resource-authorization.md +++ b/guides/authorization/live-resource-authorization.md @@ -71,26 +71,38 @@ There are two kinds of checks, and both run: | `Backpex.Resource.update/6` | `:edit` | the item | | `Backpex.Resource.update_all/5` | `:edit` | each item | | `Backpex.Resource.delete_all/4` | `:delete` | each item | -| item action, before the confirm modal opens | the action key | each selected item | -| item action, before `handle/3` runs | the action key | each selected item | +| item action without a confirm modal, before `handle/3` runs | the action key | each selected item | +| item action with a confirm modal, on open and on submit | the action key | each selected item | | resource action, on open and on submit | the action key | `nil` | The `Backpex.Resource` gates run **before** the changeset is built and before `c:Backpex.Field.before_changeset/6` is called, so your own code never executes for an unauthorized request. +Each gesture runs exactly one gate per step, so `can?/3` is not evaluated more times than there are decisions to make. A modal flow has two steps on purpose: the second check catches a permission revoked, or a selection widened, while the modal was open. + ### Strict semantics Checks over a selection are strict: a single unauthorized item raises, and nothing runs. Backpex does not silently drop items from a selection. A `nil` item — a stale or forged id — raises `Backpex.NoResultsError` (404) and never reaches your `can?/3`, so you do not need clauses for it. -Because a mixed selection would raise, the bulk action button is disabled whenever the selection is empty or contains any unauthorized item. +Because a mixed selection would raise, the bulk action button is disabled whenever the selection is empty or contains any unauthorized item. A row that is authorized for none of the bulk actions cannot be selected at all — its checkbox is disabled, so a user cannot build a selection that has no usable action. + +### What `handle/3` may assume + +The items handed to `c:Backpex.ItemAction.handle/3` have already been authorized under that action's key. Writing exactly those items back is the same decision the gate just made, so pass `authorize?: false` rather than paying for a second evaluation of your `can?/3`: + +```elixir +Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, authorize?: false) +``` + +The guarantee covers only those items under that key. Writes to *other* items or to another resource keep the default gate. ### Overriding the action and the escape hatch Every `Backpex.Resource` mutation accepts two options: -- `:authorization_action` — authorize against this action instead of the default. Item actions should pass `socket.assigns.item_action_key`, which Backpex sets before calling `c:Backpex.ItemAction.handle/3`, so an action registered under a custom key is authorized under that key. -- `authorize?: false` — skip the check. Use this for system or cascade writes that are not a user-initiated action on the resource being written, for example nullifying a foreign key on another resource. +- `:authorization_action` — authorize against this action instead of the default. It must be a non-nil atom. Item actions can pass `socket.assigns.item_action_key`, which Backpex sets before calling `c:Backpex.ItemAction.handle/3`, so an action registered under a custom key is authorized under that key. +- `authorize?: false` — skip the check. Use this for a write the gate already covered (see above), and for system or cascade writes that are not a user-initiated action on the resource being written, for example nullifying a foreign key on another resource. ```elixir Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, MyAppWeb.PostLive, diff --git a/guides/upgrading/v0.21.md b/guides/upgrading/v0.21.md index eec794128..1f7e29b70 100644 --- a/guides/upgrading/v0.21.md +++ b/guides/upgrading/v0.21.md @@ -101,23 +101,43 @@ 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. -### Overriding the action: `:authorization_action` +### What an item action's `handle/3` may assume -An item action registered under a custom key should authorize against that key: +Backpex calls `c:Backpex.ItemAction.handle/3` only after the gate authorized +**exactly those items under exactly that action's key**. 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`: ```elixir def handle(socket, items, _data) do - Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, - authorization_action: socket.assigns.item_action_key - ) + # 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: + +```elixir +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 `c:Backpex.ItemAction.handle/3`, so the action does not need to know -which key it was registered under. +calling `c: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` @@ -133,7 +153,8 @@ Backpex.Resource.update_all(item.posts, [set: [user_id: nil]], socket.assigns, M ``` The option is deliberately explicit and greppable. Reach for it only when the -write really is not the user's action on that resource. +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 @@ -150,7 +171,11 @@ which usually reported success. That is gone. 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. + 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. Two consequences worth knowing: @@ -172,8 +197,11 @@ shorter list. ## 7. Do not let a `rescue` swallow the gate -A broad `rescue` around a `Backpex.Resource` call will now catch -`Backpex.ForbiddenError` and turn a 403 into a flash message. Reraise it: +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 denied +write into a flash message that reports the action as merely failed. Reraise +them: ```elixir def handle(socket, items, _data) do @@ -187,19 +215,18 @@ rescue end ``` -Backpex's built-in delete action does this. Check your own actions for the same -pattern. - ## 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 registered under a custom key pass - `authorization_action: socket.assigns.item_action_key`. +- [ ] 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 reraise `Backpex.ForbiddenError` and - `Backpex.NoResultsError`. +- [ ] 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 `[]`). diff --git a/lib/backpex/item_actions/delete.ex b/lib/backpex/item_actions/delete.ex index 6d979b5a5..7101c3989 100644 --- a/lib/backpex/item_actions/delete.ex +++ b/lib/backpex/item_actions/delete.ex @@ -44,9 +44,10 @@ defmodule Backpex.ItemActions.Delete do def handle(socket, items, _data) do %{live_resource: live_resource} = socket.assigns - opts = [authorization_action: Map.get(socket.assigns, :item_action_key, :delete)] - - {:ok, deleted_items} = Resource.delete_all(items, socket.assigns, live_resource, opts) + # Backpex only calls `handle/3` once `Backpex.ItemAction.handle_item_action/5` (or the modal's + # submit gate) has authorized exactly these items under exactly this action's key. Re-checking + # them here would run the user's `can?/3` a second time for the same decision. + {:ok, deleted_items} = Resource.delete_all(items, socket.assigns, live_resource, authorize?: false) Enum.each(deleted_items, fn deleted_item -> live_resource.on_item_deleted(socket, deleted_item) end) @@ -55,10 +56,6 @@ defmodule Backpex.ItemActions.Delete do |> put_flash(:info, success_message(socket.assigns, deleted_items)) |> ok() rescue - # An authorization failure must reach the router as a 403, not become a flash message. - error in [Backpex.ForbiddenError, Backpex.NoResultsError] -> - reraise error, __STACKTRACE__ - error -> Logger.error("An error occurred while deleting the resource: #{inspect(error)}") diff --git a/lib/backpex/item_actions/item_action.ex b/lib/backpex/item_actions/item_action.ex index ec91f4e18..0d96ef872 100644 --- a/lib/backpex/item_actions/item_action.ex +++ b/lib/backpex/item_actions/item_action.ex @@ -262,6 +262,31 @@ defmodule Backpex.ItemAction do ] end + @doc """ + Resolves a client-supplied item action key and decides how the gesture proceeds. + + Returns `{:confirm, key, action}` for an action that has a confirmation modal and + `{:dispatch, key, action}` for one that runs immediately. An unregistered key raises + `Backpex.NoResultsError`. + + Only the `{:confirm, _key, _action}` path is authorized here, before the modal opens: an + unauthorized selection must not even get a confirm dialog, and the modal's submit re-checks it. + The `{:dispatch, _key, _action}` path is deliberately *not* authorized here — `handle_item_action/5` + gates it, and that is the authoritative execution gate. Checking in both places would evaluate + `c:Backpex.LiveResource.can?/3` twice per item for one decision. + """ + def resolve_item_action!(socket, key, items) when is_list(items) do + {key, action} = Backpex.LiveResource.fetch_action!(socket.assigns.item_actions, key) + + if has_confirm_modal?(action) do + Backpex.Authorization.authorize_all!(socket.assigns.live_resource, socket.assigns, key, items) + + {:confirm, key, action} + else + {:dispatch, key, action} + end + end + @doc """ Handles an item action by executing the action's handle function. @@ -270,8 +295,11 @@ defmodule Backpex.ItemAction do raises `Backpex.NoResultsError`. Items are never silently dropped from the selection. `c:handle/3` receives the full list of items, and `assigns.item_action_key` is set to the key the - action is registered under. Pass it as `:authorization_action` to `Backpex.Resource` functions so - actions registered under a custom key authorize against that key. + action is registered under. + + Because this gate covered exactly these items under exactly this key, a `Backpex.Resource` call + inside `c:handle/3` that writes those same items should pass `authorize?: false` rather than + repeat the check. Writes to *other* items or resources keep the default gate. When `items` is empty, `c:handle/3` is not called at all — only `after_handle` runs. """ diff --git a/lib/backpex/live_resource/index.ex b/lib/backpex/live_resource/index.ex index d7b2d8cb3..da5ffc2a2 100644 --- a/lib/backpex/live_resource/index.ex +++ b/lib/backpex/live_resource/index.ex @@ -7,6 +7,7 @@ defmodule Backpex.LiveResource.Index do alias Backpex.Adapters.Ecto, as: EctoAdapter alias Backpex.Authorization alias Backpex.FilterValidation + alias Backpex.ItemAction alias Backpex.LiveResource alias Backpex.PaginationValidation alias Backpex.Preferences @@ -300,29 +301,23 @@ defmodule Backpex.LiveResource.Index do end defp maybe_handle_item_action(socket, key) do - {key, action} = LiveResource.fetch_action!(socket.assigns.item_actions, key) items = socket.assigns.selected_items - # Gate before the modal opens: an unauthorized selection must not even get a confirm dialog. - # `Backpex.ItemAction.handle_item_action/5` checks again as defense in depth. - Authorization.authorize_all!(socket.assigns.live_resource, socket.assigns, key, items) - - if Backpex.ItemAction.has_confirm_modal?(action) do - open_action_confirm_modal(socket, action, key) - else - handle_item_action(socket, action, key, items) + case ItemAction.resolve_item_action!(socket, key, items) do + {:confirm, key, action} -> open_action_confirm_modal(socket, action, key) + {:dispatch, key, action} -> handle_item_action(socket, action, key, items) end end defp open_action_confirm_modal(socket, action, key) do socket - |> Backpex.ItemAction.assign_action_changeset(action) + |> ItemAction.assign_action_changeset(action) |> assign(:action_to_confirm, Map.put(action, :key, key)) |> noreply() end defp handle_item_action(socket, action, key, items) do - Backpex.ItemAction.handle_item_action(socket, action, key, items, fn socket -> + ItemAction.handle_item_action(socket, action, key, items, fn socket -> socket |> assign(action_to_confirm: nil) |> assign(selected_items: []) @@ -488,7 +483,7 @@ defmodule Backpex.LiveResource.Index do end defp assign_item_actions(socket) do - item_actions = Backpex.ItemAction.default_actions() |> socket.assigns.live_resource.item_actions() + item_actions = ItemAction.default_actions() |> socket.assigns.live_resource.item_actions() assign(socket, :item_actions, item_actions) end diff --git a/lib/backpex/live_resource/show.ex b/lib/backpex/live_resource/show.ex index 564f5069d..229390aaf 100644 --- a/lib/backpex/live_resource/show.ex +++ b/lib/backpex/live_resource/show.ex @@ -5,7 +5,7 @@ defmodule Backpex.LiveResource.Show do import Phoenix.Component alias Backpex.Authorization - alias Backpex.LiveResource + alias Backpex.ItemAction alias Backpex.Resource alias Backpex.Router @@ -71,22 +71,16 @@ defmodule Backpex.LiveResource.Show do end defp assign_item_actions(socket) do - item_actions = Backpex.ItemAction.default_actions() |> socket.assigns.live_resource.item_actions() + item_actions = ItemAction.default_actions() |> socket.assigns.live_resource.item_actions() assign(socket, :item_actions, item_actions) end defp maybe_handle_item_action(socket, key) do - {key, action} = LiveResource.fetch_action!(socket.assigns.item_actions, key) item = socket.assigns.item - # Gate before the modal opens: an unauthorized item must not even get a confirm dialog. - # `Backpex.ItemAction.handle_item_action/5` checks again as defense in depth. - Authorization.authorize_all!(socket.assigns.live_resource, socket.assigns, key, [item]) - - if Backpex.ItemAction.has_confirm_modal?(action) do - open_action_confirm_modal(socket, action, key) - else - handle_item_action(socket, action, key, item) + case ItemAction.resolve_item_action!(socket, key, [item]) do + {:confirm, key, action} -> open_action_confirm_modal(socket, action, key) + {:dispatch, key, action} -> handle_item_action(socket, action, key, item) end end @@ -96,7 +90,7 @@ defmodule Backpex.LiveResource.Show do socket |> assign(:selected_items, [item]) - |> Backpex.ItemAction.assign_action_changeset(action) + |> ItemAction.assign_action_changeset(action) |> assign(:return_to, return_to(socket, index_path)) |> assign(:action_to_confirm, Map.put(action, :key, key)) |> noreply() @@ -106,7 +100,7 @@ defmodule Backpex.LiveResource.Show do %{live_resource: live_resource, params: params} = socket.assigns index_path = Router.get_path(socket, live_resource, params, :index) - Backpex.ItemAction.handle_item_action(socket, action, key, [item], fn socket -> + ItemAction.handle_item_action(socket, action, key, [item], fn socket -> socket |> assign(action_to_confirm: nil) |> maybe_navigate(return_to(socket, index_path)) From 99eca56f8cbdf1203248cdbcd8e1fa25273e4308 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:37:00 +0200 Subject: [PATCH 18/29] Scope the item_action_key assign to a single dispatch The assign was set before handle/3 and never cleared, so it leaked into later dispatches and into every component rendered afterwards. Move setting and clearing it into Backpex.ItemAction.dispatch/5, used by both the immediate and the form dispatch path. --- guides/actions/item-actions.md | 2 +- .../live-resource-authorization.md | 2 +- lib/backpex/item_actions/item_action.ex | 28 +++++++++++++++---- lib/backpex/live_components/form_component.ex | 4 +-- test/backpex/item_action_test.exs | 17 +++++++++++ 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/guides/actions/item-actions.md b/guides/actions/item-actions.md index f3f2ad02f..7ae72d6ad 100644 --- a/guides/actions/item-actions.md +++ b/guides/actions/item-actions.md @@ -240,7 +240,7 @@ Backpex enforces this for you — you do not need to check it again inside `c:Ba Backpex.Resource.delete_all(items, socket.assigns, socket.assigns.live_resource, authorize?: false) ``` -Anything else the action writes is *not* covered by that gate and keeps the default check. `Backpex.Resource` mutations default to `:new` / `:edit` / `:delete`; pass `:authorization_action` when a different key is the right one to check. `assigns.item_action_key` holds the key this action is registered under while `handle/3` runs, so the action does not need to hardcode it: +Anything else the action writes is *not* covered by that gate and keeps the default check. `Backpex.Resource` mutations default to `:new` / `:edit` / `:delete`; pass `:authorization_action` when a different key is the right one to check. `assigns.item_action_key` holds the key this action is registered under for the duration of the `handle/3` call — Backpex clears it again afterwards — so the action does not need to hardcode it: ```elixir # writing other items of the same resource, under this action's key diff --git a/guides/authorization/live-resource-authorization.md b/guides/authorization/live-resource-authorization.md index 47855ca01..9aa124122 100644 --- a/guides/authorization/live-resource-authorization.md +++ b/guides/authorization/live-resource-authorization.md @@ -101,7 +101,7 @@ The guarantee covers only those items under that key. Writes to *other* items or Every `Backpex.Resource` mutation accepts two options: -- `:authorization_action` — authorize against this action instead of the default. It must be a non-nil atom. Item actions can pass `socket.assigns.item_action_key`, which Backpex sets before calling `c:Backpex.ItemAction.handle/3`, so an action registered under a custom key is authorized under that key. +- `:authorization_action` — authorize against this action instead of the default. It must be a non-nil atom. Item actions can pass `socket.assigns.item_action_key`, so an action registered under a custom key is authorized under that key. Backpex sets that assign just before calling `c:Backpex.ItemAction.handle/3` and clears it again when the call returns, so it is meaningful for exactly one dispatch. - `authorize?: false` — skip the check. Use this for a write the gate already covered (see above), and for system or cascade writes that are not a user-initiated action on the resource being written, for example nullifying a foreign key on another resource. ```elixir diff --git a/lib/backpex/item_actions/item_action.ex b/lib/backpex/item_actions/item_action.ex index 0d96ef872..49e4b559c 100644 --- a/lib/backpex/item_actions/item_action.ex +++ b/lib/backpex/item_actions/item_action.ex @@ -294,8 +294,8 @@ defmodule Backpex.ItemAction do unauthorized item raises `Backpex.ForbiddenError`, and a `nil` entry (a stale or forged item id) raises `Backpex.NoResultsError`. Items are never silently dropped from the selection. - `c:handle/3` receives the full list of items, and `assigns.item_action_key` is set to the key the - action is registered under. + `c:handle/3` receives the full list of items, and `assigns.item_action_key` is available for the + duration of that call only — see `dispatch/5`. Because this gate covered exactly these items under exactly this key, a `Backpex.Resource` call inside `c:handle/3` that writes those same items should pass `authorize?: false` rather than @@ -311,9 +311,7 @@ defmodule Backpex.ItemAction do if items == [] do after_handle.(socket) else - socket = assign(socket, :item_action_key, key) - - case action.module.handle(socket, items, %{}) do + case dispatch(socket, action, key, items, %{}) do {:ok, socket} -> after_handle.(socket) @@ -330,6 +328,26 @@ defmodule Backpex.ItemAction do end end + @doc """ + Calls `c:handle/3` with `assigns.item_action_key` set to `key`, and clears the assign again on the + socket the action returns. + + The key is scoped to exactly one dispatch. Leaving it set would let a later dispatch — or any + component rendered afterwards — read the key of an action that already finished. + + Returns whatever `c:handle/3` returned. Authorization is the caller's job: this only runs the + action. + """ + def dispatch(socket, action, key, items, data) do + socket + |> assign(:item_action_key, key) + |> action.module.handle(items, data) + |> case do + {:ok, socket} -> {:ok, assign(socket, :item_action_key, nil)} + other -> other + end + end + @doc """ Prepares the socket for opening an action confirmation modal. diff --git a/lib/backpex/live_components/form_component.ex b/lib/backpex/live_components/form_component.ex index 622cb3baa..7407b215c 100644 --- a/lib/backpex/live_components/form_component.ex +++ b/lib/backpex/live_components/form_component.ex @@ -421,10 +421,8 @@ defmodule Backpex.FormComponent do {:ok, %{}} end - socket = assign(socket, :item_action_key, action_key) - with {:ok, data} <- result, - {:ok, socket} <- action_to_confirm.module.handle(socket, selected_items, data) do + {:ok, socket} <- ItemAction.dispatch(socket, action_to_confirm, action_key, selected_items, data) do socket |> assign(:show_form_errors, false) |> assign(:selected_items, []) diff --git a/test/backpex/item_action_test.exs b/test/backpex/item_action_test.exs index 7955f91b7..ae205e1d4 100644 --- a/test/backpex/item_action_test.exs +++ b/test/backpex/item_action_test.exs @@ -37,6 +37,23 @@ defmodule Backpex.ItemActionTest do assert_received {:handled, ^items, :user_soft_delete} end + test "clears item_action_key once the dispatch is over" do + socket = build_socket(AllowAll) + + # The key belongs to one dispatch. Leaving it set would let a later dispatch, or anything + # rendered afterwards, read the key of an action that already finished. + assert {:after_handle, socket} = + ItemAction.handle_item_action( + socket, + %{module: EchoAction}, + :user_soft_delete, + [%{id: 1}], + &after_handle/1 + ) + + assert socket.assigns.item_action_key == nil + end + test "raises ForbiddenError when a single item is unauthorized and never calls handle/3" do items = [%{id: 1, role: :user}, %{id: 2, role: :admin}] socket = build_socket(NoAdmins) From 2a60b4d7d26d4f4be6ac96406403661f1dc4f5d1 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:37:58 +0200 Subject: [PATCH 19/29] Collapse the duplicated close sequence in the item action form path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit empty_item_action_selection/2 repeated the success branch of run_form_item_action byte for byte; both now call one close_item_action/2. Also drops the re-destructuring of assigns between handle_form_item_action and run_form_item_action — the values are passed along instead. --- lib/backpex/live_components/form_component.ex | 46 +++++++------------ 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/lib/backpex/live_components/form_component.ex b/lib/backpex/live_components/form_component.ex index 7407b215c..06b308ecc 100644 --- a/lib/backpex/live_components/form_component.ex +++ b/lib/backpex/live_components/form_component.ex @@ -366,24 +366,27 @@ defmodule Backpex.FormComponent do %{ live_resource: live_resource, selected_items: selected_items, - action_to_confirm: action_to_confirm, + action_to_confirm: action, return_to: return_to } = assigns } = socket - action_key = action_to_confirm.key + action_key = action.key - # Gate before any changeset work: permission may have been revoked while the modal was open. + # Gate before any changeset work: permission may have been revoked, or the selection widened, + # while the modal was open. Authorization.authorize_all!(live_resource, assigns, action_key, selected_items) if selected_items == [] do - empty_item_action_selection(socket, return_to) + close_item_action(socket, return_to) else - run_form_item_action(socket, action_key, params) + run_form_item_action(socket, action, action_key, selected_items, return_to, params) end end - defp empty_item_action_selection(socket, return_to) do + # The selection is done with either way: whether the action ran or there was nothing to run it + # on, the modal closes, the selection is dropped and we return to where we came from. + defp close_item_action(socket, return_to) do socket |> assign(:show_form_errors, false) |> assign(:selected_items, []) @@ -392,29 +395,17 @@ defmodule Backpex.FormComponent do |> noreply() end - defp run_form_item_action(socket, action_key, params) do - %{ - assigns: - %{ - fields: fields, - selected_items: selected_items, - action_to_confirm: action_to_confirm, - return_to: return_to - } = assigns - } = socket + defp run_form_item_action(socket, action, action_key, selected_items, return_to, params) do + %{assigns: %{fields: fields} = assigns} = socket params = drop_readonly_changes(params, fields, assigns) result = - if ItemAction.has_form?(action_to_confirm) do - changeset_function = fn item, changes, metadata -> - action_to_confirm.module.changeset(item, changes, metadata) - end - + if ItemAction.has_form?(action) do metadata = Resource.build_changeset_metadata(assigns) assigns.action_item - |> changeset_function.(params, metadata) + |> action.module.changeset(params, metadata) |> Map.put(:action, :insert) |> Ecto.Changeset.apply_action(:insert) else @@ -422,13 +413,8 @@ defmodule Backpex.FormComponent do end with {:ok, data} <- result, - {:ok, socket} <- ItemAction.dispatch(socket, action_to_confirm, action_key, selected_items, data) do - socket - |> assign(:show_form_errors, false) - |> assign(:selected_items, []) - |> assign(:select_all, false) - |> push_navigate(to: return_to) - |> noreply() + {:ok, socket} <- ItemAction.dispatch(socket, action, action_key, selected_items, data) do + close_item_action(socket, return_to) else {:error, changeset} -> form = Component.to_form(changeset, as: :change) @@ -440,7 +426,7 @@ defmodule Backpex.FormComponent do unexpected_return -> raise ArgumentError, """ - Invalid return value from #{inspect(action_to_confirm.module)}.handle/2. + Invalid return value from #{inspect(action.module)}.handle/2. Expected: {:ok, socket} or {:error, changeset} Got: #{inspect(unexpected_return)} From f2019e14a0bc4289481298170bc2d1ce20633b2a Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Wed, 26 Aug 2026 17:41:02 +0200 Subject: [PATCH 20/29] Give mixed-authorization selections an affordance Strict enforcement disabled the toolbar button for a mixed selection with nothing saying why. A row that is authorized for none of the bulk actions is now unselectable (visible but disabled, with an accessible explanation), select-all skips those rows, and every disabled toolbar button carries a title saying what is wrong with the selection. --- .../live/authorization_enforcement_test.exs | 45 ++++++++++++- lib/backpex/html/resource.ex | 66 +++++++++++++++++-- .../resource/resource_index_table.html.heex | 12 ++-- lib/backpex/live_resource/index.ex | 17 +++-- priv/gettext/backpex.pot | 16 +++++ 5 files changed, 140 insertions(+), 16 deletions(-) diff --git a/demo/test/demo_web/live/authorization_enforcement_test.exs b/demo/test/demo_web/live/authorization_enforcement_test.exs index fc3cfb77b..170bfac74 100644 --- a/demo/test/demo_web/live/authorization_enforcement_test.exs +++ b/demo/test/demo_web/live/authorization_enforcement_test.exs @@ -119,14 +119,57 @@ defmodule DemoWeb.Live.AuthorizationEnforcementTest do %{user: insert(:user, %{role: :user}), admin: insert(:user, %{role: :admin})} end - test "disable the bulk action button", %{conn: conn, user: user, admin: admin} do + test "disable the bulk action button and say why", %{conn: conn, user: user, admin: admin} do {:ok, view, _html} = live(conn, ~p"/admin/users") render_click(view, "update-selected-items", %{"id" => user.id}) refute has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + # The admin's checkbox is disabled in the UI, so this selection can only be built by forging + # the event — but the button must still explain itself rather than being a dead end. render_click(view, "update-selected-items", %{"id" => admin.id}) + assert has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + + assert has_element?( + view, + "button[phx-value-action-key='user_soft_delete'][title='Your selection contains items you may not apply this action to.']" + ) + end + + test "an item no action applies to cannot be selected", %{conn: conn, user: user, admin: admin} do + {:ok, view, _html} = live(conn, ~p"/admin/users") + + # `user_soft_delete` is the only bulk action on users and it is denied for admins, so an + # admin row can never take part in one. + assert has_element?(view, "#select-input-#{admin.id}[disabled]") + refute has_element?(view, "#select-input-#{user.id}[disabled]") + + assert has_element?( + view, + "#select-input-#{admin.id}[title='No action is available for this item.']" + ) + end + + test "select all skips items no action applies to", %{conn: conn, user: user, admin: admin} do + {:ok, view, _html} = live(conn, ~p"/admin/users") + + render_click(view, "toggle-item-selection", %{}) + + assert has_element?(view, "#select-input-#{user.id}[checked]") + refute has_element?(view, "#select-input-#{admin.id}[checked]") + + # A select-all that produced an unusable selection would be the dead end this avoids. + refute has_element?(view, "button[phx-value-action-key='user_soft_delete'][disabled]") + end + + test "empty selections say what to do instead of just being disabled", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/users") + + assert has_element?( + view, + "button[phx-value-action-key='user_soft_delete'][title='Select at least one item to use this action.']" + ) end test "raise ForbiddenError when the bulk action is forged anyway", %{conn: conn, user: user, admin: admin} do diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 269260eec..7e9651f40 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -897,9 +897,10 @@ defmodule Backpex.HTML.Resource do