From 22eb1cbb2faeb44bd2edf284a618ec25c187be87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Fri, 29 May 2026 14:36:05 +0200 Subject: [PATCH 1/4] Derive Permit action names from Ash domain resources --- lib/permit_ash/actions.ex | 64 +++++++++++++++++++++++ lib/permit_ash/authorizer.ex | 14 ++++- lib/permit_ash/resource.ex | 58 ++++++++++++++++++++ lib/permit_ash/resource/action_mapping.ex | 4 ++ lib/permit_ash/resource/info.ex | 35 +++++++++++++ test/permit_ash/actions_test.exs | 41 +++++++++++++++ test/permit_ash/authorizer_test.exs | 43 +++++++++++++++ test/support/ash_actions.ex | 4 ++ test/support/post.ex | 13 ++++- 9 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 lib/permit_ash/actions.ex create mode 100644 lib/permit_ash/resource.ex create mode 100644 lib/permit_ash/resource/action_mapping.ex create mode 100644 lib/permit_ash/resource/info.ex create mode 100644 test/permit_ash/actions_test.exs create mode 100644 test/support/ash_actions.ex diff --git a/lib/permit_ash/actions.ex b/lib/permit_ash/actions.ex new file mode 100644 index 0000000..b01ffbb --- /dev/null +++ b/lib/permit_ash/actions.ex @@ -0,0 +1,64 @@ +defmodule Permit.Ash.Actions do + @moduledoc """ + A `Permit.Actions` implementation that derives its grouping schema from an + Ash domain at compile time. + + All action names defined across the domain's resources become standalone + Permit action names with no hierarchy — every action must be granted + explicitly in the permissions module. This is the recommended approach + for new projects using Permit with Ash. + + ## Usage + + defmodule MyApp.AshActions do + use Permit.Ash.Actions, domain: MyApp.Domain + end + + defmodule MyApp.Permissions do + use Permit.Permissions, actions_module: MyApp.AshActions + + def can(%User{role: :admin}) do + permit() + |> all(Post) + end + + def can(%User{id: user_id}) do + permit() + |> read(Post) + |> archive(Post, user_id: user_id) + end + + def can(_), do: permit() + end + + Because `grouping_schema/0` is called at compile time when the permissions + module is compiled, the domain must be fully compiled before the actions + module that references it. + """ + + defmacro __using__(opts) do + domain = Keyword.fetch!(opts, :domain) + + quote do + use Permit.Actions + + @impl Permit.Actions + def grouping_schema do + Permit.Ash.Actions.derive_grouping_schema(unquote(domain)) + end + end + end + + @doc false + def derive_grouping_schema(domain) do + domain + |> Ash.Domain.Info.resources() + |> Enum.flat_map(fn resource -> + resource + |> Ash.Resource.Info.actions() + |> Enum.map(& &1.name) + end) + |> Enum.uniq() + |> Map.new(&{&1, []}) + end +end diff --git a/lib/permit_ash/authorizer.ex b/lib/permit_ash/authorizer.ex index 19ff105..d798d1d 100644 --- a/lib/permit_ash/authorizer.ex +++ b/lib/permit_ash/authorizer.ex @@ -18,16 +18,26 @@ defmodule Permit.Ash.Authorizer do domain: domain, # Ash :actor <=> Permit :subject # Ash :resource <=> Permit :resource (module or struct) - # Ash :action's :name <=> Permit :action + # Ash :action's :name <=> Permit :action (resolved via map_action if declared) permit: %{ subject: actor, resource: resource, - action: action.name, + action: resolve_permit_action(resource, action), authorization_module: authorization_module } } end + # Resolves the Permit action atom for a given Ash action. + # Checks for an explicit map_action declaration on the resource first; + # falls back to the Ash action name directly if none is found. + defp resolve_permit_action(resource, action) do + case Permit.Ash.Resource.Info.action_mapping(resource, action.name) do + {:ok, permit_action} -> permit_action + :error -> action.name + end + end + @impl true def strict_check_context(_state), do: [] diff --git a/lib/permit_ash/resource.ex b/lib/permit_ash/resource.ex new file mode 100644 index 0000000..b892833 --- /dev/null +++ b/lib/permit_ash/resource.ex @@ -0,0 +1,58 @@ +defmodule Permit.Ash.Resource do + @moduledoc """ + An optional Spark DSL extension for Ash resources that provides Permit + authorization configuration. + + ## Usage + + defmodule MyApp.Post do + use Ash.Resource, extensions: [Permit.Ash.Resource] + + permit do + map_action :archive, to: :update + end + end + + ## `map_action` + + When an Ash action name differs from the Permit action name you want to + authorize against, `map_action` declares the per-resource resolution. The + authorizer substitutes the mapped Permit action name before performing + any authorization checks. + + Mappings are independent per resource — `Post` and `Comment` can map + `:archive` to different Permit actions without conflict. + + Without any `map_action` declarations the authorizer uses the Ash action + name directly, which is the recommended approach when using + `Permit.Ash.Actions` as your actions module. + """ + + @map_action %Spark.Dsl.Entity{ + name: :map_action, + describe: "Maps an Ash action name to a Permit action name for authorization.", + args: [:action_name], + target: Permit.Ash.Resource.ActionMapping, + identifier: :action_name, + schema: [ + action_name: [ + type: :atom, + required: true, + doc: "The Ash action name to map." + ], + to: [ + type: :atom, + required: true, + doc: "The Permit action name to resolve to during authorization." + ] + ] + } + + @permit %Spark.Dsl.Section{ + name: :permit, + describe: "Permit authorization configuration for this Ash resource.", + entities: [@map_action] + } + + use Spark.Dsl.Extension, sections: [@permit] +end diff --git a/lib/permit_ash/resource/action_mapping.ex b/lib/permit_ash/resource/action_mapping.ex new file mode 100644 index 0000000..34ed48f --- /dev/null +++ b/lib/permit_ash/resource/action_mapping.ex @@ -0,0 +1,4 @@ +defmodule Permit.Ash.Resource.ActionMapping do + @moduledoc false + defstruct [:action_name, :to, :__identifier__, :__spark_metadata__] +end diff --git a/lib/permit_ash/resource/info.ex b/lib/permit_ash/resource/info.ex new file mode 100644 index 0000000..b3745fd --- /dev/null +++ b/lib/permit_ash/resource/info.ex @@ -0,0 +1,35 @@ +defmodule Permit.Ash.Resource.Info do + @moduledoc """ + Introspection helpers for the `Permit.Ash.Resource` DSL extension. + + ## Generated functions + + - `permit/1` — returns the list of all entities in the `permit` section + (i.e. `map_action` declarations), or `[]` if none are defined or the + extension is not loaded on the resource. + """ + + use Spark.InfoGenerator, + extension: Permit.Ash.Resource, + sections: [:permit] + + @doc """ + Returns `{:ok, permit_action}` if a `map_action` is declared for `action_name` + on `resource`, or `:error` if none is defined. + + Safe to call on any Ash resource regardless of whether `Permit.Ash.Resource` + is loaded as an extension — returns `:error` in that case. + """ + @spec action_mapping(module(), atom()) :: {:ok, atom()} | :error + def action_mapping(resource, action_name) do + resource + |> permit() + |> Enum.find(&(&1.action_name == action_name)) + |> case do + nil -> :error + %{to: permit_action} -> {:ok, permit_action} + end + rescue + _ -> :error + end +end diff --git a/test/permit_ash/actions_test.exs b/test/permit_ash/actions_test.exs new file mode 100644 index 0000000..7d8da70 --- /dev/null +++ b/test/permit_ash/actions_test.exs @@ -0,0 +1,41 @@ +defmodule Permit.Ash.ActionsTest do + use ExUnit.Case, async: true + + alias Permit.Ash.Test.AshActions + + describe "grouping_schema/0" do + test "contains all action names from all domain resources" do + schema = AshActions.grouping_schema() + + # Post actions: :read, :destroy, :create, :update, :publish + # Author actions: :read, :destroy, :create (deduplicated) + for action <- [:read, :destroy, :create, :update, :publish] do + assert Map.has_key?(schema, action), + "expected #{inspect(action)} in grouping schema, got: #{inspect(Map.keys(schema))}" + end + end + + test "all actions are standalone — empty dependency lists" do + schema = AshActions.grouping_schema() + assert Enum.all?(schema, fn {_, deps} -> deps == [] end) + end + + test "deduplicates action names that appear across multiple resources" do + schema = AshActions.grouping_schema() + keys = Map.keys(schema) + # :read, :destroy, :create appear on both Post and Author + assert Enum.count(keys, &(&1 == :read)) == 1 + assert Enum.count(keys, &(&1 == :create)) == 1 + assert Enum.count(keys, &(&1 == :destroy)) == 1 + end + end + + describe "list_groups/1" do + test "returns all unique action atoms" do + groups = Permit.Actions.list_groups(AshActions) + assert :read in groups + assert :update in groups + assert :publish in groups + end + end +end diff --git a/test/permit_ash/authorizer_test.exs b/test/permit_ash/authorizer_test.exs index 29082e0..f6b0e13 100644 --- a/test/permit_ash/authorizer_test.exs +++ b/test/permit_ash/authorizer_test.exs @@ -360,6 +360,49 @@ defmodule Permit.Ash.AuthorizerTest do end end + # --------------------------------------------------------------------------- + # map_action — custom Ash action name resolved to a Permit action name + # Post declares: map_action :publish, to: :update + # The authorizer substitutes :update so existing update permissions apply. + # --------------------------------------------------------------------------- + + describe "map_action — :publish resolves to :update" do + test "owner can publish their own post (update permission covers mapped action)" do + post = create_post!(%{user_id: 1, published: false}) + + assert {:ok, updated} = + post + |> Ash.Changeset.for_update(:publish, %{published: true}, + actor: %{id: 1, role: :owner} + ) + |> Ash.update() + + assert updated.published == true + end + + test "owner cannot publish another user's post" do + post = create_post!(%{user_id: 2, published: false}) + + assert {:error, _} = + post + |> Ash.Changeset.for_update(:publish, %{published: true}, + actor: %{id: 1, role: :owner} + ) + |> Ash.update() + end + + test "no_access role cannot publish any post" do + post = create_post!(%{user_id: 1, published: false}) + + assert {:error, _} = + post + |> Ash.Changeset.for_update(:publish, %{published: true}, + actor: %{id: 1, role: :no_access} + ) + |> Ash.update() + end + end + # --------------------------------------------------------------------------- # Association conditions with operator tuples in nested position # FilterBuilder routes {:gt, 5} etc. through raw_op_to_module/1 → diff --git a/test/support/ash_actions.ex b/test/support/ash_actions.ex new file mode 100644 index 0000000..90c3eaa --- /dev/null +++ b/test/support/ash_actions.ex @@ -0,0 +1,4 @@ +defmodule Permit.Ash.Test.AshActions do + @moduledoc false + use Permit.Ash.Actions, domain: Permit.Ash.Test.Domain +end diff --git a/test/support/post.ex b/test/support/post.ex index 4d7f3b6..734da18 100644 --- a/test/support/post.ex +++ b/test/support/post.ex @@ -3,7 +3,8 @@ defmodule Permit.Ash.Test.Post do use Ash.Resource, domain: Permit.Ash.Test.Domain, data_layer: Ash.DataLayer.Ets, - authorizers: [Permit.Ash.Authorizer] + authorizers: [Permit.Ash.Authorizer], + extensions: [Permit.Ash.Resource] ets do # Private tables are scoped to the owning process, giving each test process @@ -44,6 +45,10 @@ defmodule Permit.Ash.Test.Post do end end + permit do + map_action :publish, to: :update + end + actions do defaults([:read, :destroy]) @@ -54,5 +59,11 @@ defmodule Permit.Ash.Test.Post do update :update do accept([:title, :published, :score]) end + + # Custom update action used to test map_action resolution: :publish maps + # to the Permit :update action, so update permissions cover it. + update :publish do + accept [:published] + end end end From c4be69042588a105e185c1d53042c232b7db491d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Fri, 29 May 2026 16:13:33 +0200 Subject: [PATCH 2/4] Formatter fixes --- .formatter.exs | 4 +++- mix.exs | 3 ++- mix.lock | 1 + test/support/post.ex | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index d2cda26..723110d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,6 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + import_deps: [:ash], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + plugins: [Spark.Formatter] ] diff --git a/mix.exs b/mix.exs index f160820..87ce4d3 100644 --- a/mix.exs +++ b/mix.exs @@ -33,7 +33,8 @@ defmodule Permit.Ash.MixProject do {:permit, "~> 0.4.1"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, - {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} + {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, + {:sourceror, "~> 1.12", only: [:dev, :test]} ] end end diff --git a/mix.lock b/mix.lock index 4632ecb..dbcaf19 100644 --- a/mix.lock +++ b/mix.lock @@ -15,6 +15,7 @@ "permit": {:hex, :permit, "0.4.1", "b6c83afb78411ceb4bbc49d27243b42308acd7f21175df4bf5d1b67d41c82ea7", [:mix], [{:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "f5d80a3dabbc5f65873079c376a5334578ca1abcc82d8e91e397c68962aa9a54"}, "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, + "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, diff --git a/test/support/post.ex b/test/support/post.ex index 734da18..5f979cc 100644 --- a/test/support/post.ex +++ b/test/support/post.ex @@ -46,7 +46,7 @@ defmodule Permit.Ash.Test.Post do end permit do - map_action :publish, to: :update + map_action(:publish, to: :update) end actions do From 779e225c84aaf82dddf395f556ad46320e481f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Fri, 29 May 2026 16:31:05 +0200 Subject: [PATCH 3/4] Suppress wrong dialyzer warning --- lib/permit_ash/authorizer.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/permit_ash/authorizer.ex b/lib/permit_ash/authorizer.ex index d798d1d..f108047 100644 --- a/lib/permit_ash/authorizer.ex +++ b/lib/permit_ash/authorizer.ex @@ -6,6 +6,11 @@ defmodule Permit.Ash.Authorizer do alias Permit.Ash.Domain.Info, as: DomainInfo alias Permit.Ash.FilterBuilder + # Ash.Authorizer types the `resource` argument of initial_state/4 as + # Ash.Resource.Record.t() (= struct()), but Ash actually passes the resource + # MODULE (an atom) at runtime. Suppress the false-positive callback mismatch. + @dialyzer {:nowarn_function, initial_state: 4} + @impl true def initial_state(actor, resource, action, domain) do # Authorization module configured in :domain via spark dsl From 199b585ef87fb29b9dec4423caced9dabf3237df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Mon, 1 Jun 2026 10:41:18 +0200 Subject: [PATCH 4/4] Update docs --- lib/permit_ash/actions.ex | 24 ++++++++++++++++++++++++ lib/permit_ash/resource.ex | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/permit_ash/actions.ex b/lib/permit_ash/actions.ex index b01ffbb..d22171b 100644 --- a/lib/permit_ash/actions.ex +++ b/lib/permit_ash/actions.ex @@ -10,10 +10,34 @@ defmodule Permit.Ash.Actions do ## Usage + Declare the custom action on the Ash resource as usual: + + defmodule MyApp.Post do + use Ash.Resource, + domain: MyApp.Domain, + authorizers: [Permit.Ash.Authorizer], + extensions: [Permit.Ash.Resource] + + actions do + defaults [:read, :destroy, :create, :update] + + update :archive do + accept [] + change set_attribute(:archived, true) + end + end + end + + Point `Permit.Ash.Actions` at the domain — it collects every action name, + including `:archive`, at compile time: + defmodule MyApp.AshActions do use Permit.Ash.Actions, domain: MyApp.Domain end + Use the generated action helper in the permissions module just like any + other Permit action: + defmodule MyApp.Permissions do use Permit.Permissions, actions_module: MyApp.AshActions diff --git a/lib/permit_ash/resource.ex b/lib/permit_ash/resource.ex index b892833..fb53bd3 100644 --- a/lib/permit_ash/resource.ex +++ b/lib/permit_ash/resource.ex @@ -6,7 +6,9 @@ defmodule Permit.Ash.Resource do ## Usage defmodule MyApp.Post do - use Ash.Resource, extensions: [Permit.Ash.Resource] + use Ash.Resource, + authorizers: [Permit.Ash.Authorizer], + extensions: [Permit.Ash.Resource] permit do map_action :archive, to: :update