From 36b5d5880b9249a7d6b3142000fe29b6a53af514 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 29 Jan 2026 12:12:07 +0100 Subject: [PATCH] Add CustomField resource policies and tests - Add policies block with HasPermission for read/create/update/destroy - Add authorizers: [Ash.Policy.Authorizer] to CustomField resource - Add custom_field_policies_test.exs (read all roles, write admin only) - Fix CustomField path in roles-and-permissions doc (lib/membership) --- docs/roles-and-permissions-architecture.md | 17 +- lib/membership/custom_field.ex | 10 +- .../membership/custom_field_policies_test.exs | 188 ++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 test/mv/membership/custom_field_policies_test.exs diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 063de32..5b930a7 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1101,28 +1101,23 @@ end ### CustomField Resource Policies -**Location:** `lib/mv/membership/custom_field.ex` +**Location:** `lib/membership/custom_field.ex` **No Special Cases:** All users can read, only admin can write. ```elixir defmodule Mv.Membership.CustomField do - use Ash.Resource, ... - + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + policies do - # All authenticated users can read custom fields (needed for forms) - # Write operations are admin-only policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end - - # DEFAULT: Forbid - policy action_type([:read, :create, :update, :destroy]) do - forbid_if always() - end end - # ... end ``` diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 94cb657..ab4ad60 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -50,7 +50,8 @@ defmodule Mv.Membership.CustomField do """ use Ash.Resource, domain: Mv.Membership, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "custom_fields" @@ -79,6 +80,13 @@ defmodule Mv.Membership.CustomField do end end + policies do + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + attributes do uuid_primary_key :id diff --git a/test/mv/membership/custom_field_policies_test.exs b/test/mv/membership/custom_field_policies_test.exs new file mode 100644 index 0000000..1e758d1 --- /dev/null +++ b/test/mv/membership/custom_field_policies_test.exs @@ -0,0 +1,188 @@ +defmodule Mv.Membership.CustomFieldPoliciesTest do + @moduledoc """ + Tests for CustomField resource authorization policies. + + Verifies that all authenticated users with a valid role can read custom fields, + and only admin can create/update/destroy custom fields. + """ + use Mv.DataCase, async: false + + alias Mv.Membership.CustomField + alias Mv.Accounts + alias Mv.Authorization + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + %{actor: system_actor} + end + + defp create_role_with_permission_set(permission_set_name, actor) do + role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}" + + case Authorization.create_role( + %{ + name: role_name, + description: "Test role for #{permission_set_name}", + permission_set_name: permission_set_name + }, + actor: actor + ) do + {:ok, role} -> role + {:error, error} -> raise "Failed to create role: #{inspect(error)}" + end + end + + defp create_user_with_permission_set(permission_set_name, actor) do + role = create_role_with_permission_set(permission_set_name, actor) + + {:ok, user} = + Accounts.User + |> Ash.Changeset.for_create(:register_with_password, %{ + email: "user#{System.unique_integer([:positive])}@example.com", + password: "testpassword123" + }) + |> Ash.create(actor: actor) + + {:ok, user} = + user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove) + |> Ash.update(actor: actor) + + {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor) + user_with_role + end + + defp create_custom_field(actor) do + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_#{System.unique_integer([:positive])}", + value_type: :string + }) + |> Ash.create(actor: actor, domain: Mv.Membership) + + field + end + + describe "read access (all roles)" do + test "user with own_data can read all custom fields", %{actor: actor} do + custom_field = create_custom_field(actor) + user = create_user_with_permission_set("own_data", actor) + + {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) + ids = Enum.map(fields, & &1.id) + assert custom_field.id in ids + + {:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership) + assert fetched.id == custom_field.id + end + + test "user with read_only can read all custom fields", %{actor: actor} do + custom_field = create_custom_field(actor) + user = create_user_with_permission_set("read_only", actor) + + {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) + ids = Enum.map(fields, & &1.id) + assert custom_field.id in ids + + {:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership) + assert fetched.id == custom_field.id + end + + test "user with normal_user can read all custom fields", %{actor: actor} do + custom_field = create_custom_field(actor) + user = create_user_with_permission_set("normal_user", actor) + + {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) + ids = Enum.map(fields, & &1.id) + assert custom_field.id in ids + + {:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership) + assert fetched.id == custom_field.id + end + + test "user with admin can read all custom fields", %{actor: actor} do + custom_field = create_custom_field(actor) + user = create_user_with_permission_set("admin", actor) + + {:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership) + ids = Enum.map(fields, & &1.id) + assert custom_field.id in ids + + {:ok, fetched} = Ash.get(CustomField, custom_field.id, actor: user, domain: Mv.Membership) + assert fetched.id == custom_field.id + end + end + + describe "write access - non-admin cannot create/update/destroy" do + setup %{actor: actor} do + user = create_user_with_permission_set("normal_user", actor) + custom_field = create_custom_field(actor) + %{user: user, custom_field: custom_field} + end + + test "non-admin cannot create custom field (forbidden)", %{user: user} do + assert {:error, %Ash.Error.Forbidden{}} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "forbidden_field_#{System.unique_integer([:positive])}", + value_type: :string + }) + |> Ash.create(actor: user, domain: Mv.Membership) + end + + test "non-admin cannot update custom field (forbidden)", %{ + user: user, + custom_field: custom_field + } do + assert {:error, %Ash.Error.Forbidden{}} = + custom_field + |> Ash.Changeset.for_update(:update, %{description: "Updated"}) + |> Ash.update(actor: user, domain: Mv.Membership) + end + + test "non-admin cannot destroy custom field (forbidden)", %{ + user: user, + custom_field: custom_field + } do + assert {:error, %Ash.Error.Forbidden{}} = + Ash.destroy(custom_field, actor: user, domain: Mv.Membership) + end + end + + describe "write access - admin can create/update/destroy" do + setup %{actor: actor} do + user = create_user_with_permission_set("admin", actor) + custom_field = create_custom_field(actor) + %{user: user, custom_field: custom_field} + end + + test "admin can create custom field", %{user: user} do + name = "admin_field_#{System.unique_integer([:positive])}" + + assert {:ok, %CustomField{} = field} = + CustomField + |> Ash.Changeset.for_create(:create, %{name: name, value_type: :string}) + |> Ash.create(actor: user, domain: Mv.Membership) + + assert field.name == name + end + + test "admin can update custom field", %{user: user, custom_field: custom_field} do + assert {:ok, updated} = + custom_field + |> Ash.Changeset.for_update(:update, %{description: "Admin updated"}) + |> Ash.update(actor: user, domain: Mv.Membership) + + assert updated.description == "Admin updated" + end + + test "admin can destroy custom field", %{user: user, custom_field: custom_field} do + assert :ok = Ash.destroy(custom_field, actor: user, domain: Mv.Membership) + + assert {:error, _} = + Ash.get(CustomField, custom_field.id, domain: Mv.Membership, actor: user) + end + end +end