Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -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]
]
88 changes: 88 additions & 0 deletions lib/permit_ash/actions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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

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

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
19 changes: 17 additions & 2 deletions lib/permit_ash/authorizer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,16 +23,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: []

Expand Down
60 changes: 60 additions & 0 deletions lib/permit_ash/resource.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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,
authorizers: [Permit.Ash.Authorizer],
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
4 changes: 4 additions & 0 deletions lib/permit_ash/resource/action_mapping.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule Permit.Ash.Resource.ActionMapping do
@moduledoc false
defstruct [:action_name, :to, :__identifier__, :__spark_metadata__]
end
35 changes: 35 additions & 0 deletions lib/permit_ash/resource/info.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
41 changes: 41 additions & 0 deletions test/permit_ash/actions_test.exs
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions test/permit_ash/authorizer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
4 changes: 4 additions & 0 deletions test/support/ash_actions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule Permit.Ash.Test.AshActions do
@moduledoc false
use Permit.Ash.Actions, domain: Permit.Ash.Test.Domain
end
13 changes: 12 additions & 1 deletion test/support/post.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])

Expand All @@ -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
Loading