diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index 98f8253..f0dd1a7 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -84,7 +84,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do end end + # READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only. policies do + bypass action_type(:read) do + description "own_data: read only cycles where member_id == actor.member_id" + authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData + end + policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from role (all read; normal_user and admin create/update/destroy)" authorize_if Mv.Authorization.Checks.HasPermission diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex index f2b302d..1139c3c 100644 --- a/lib/mv/authorization/checks/has_permission.ex +++ b/lib/mv/authorization/checks/has_permission.ex @@ -351,6 +351,10 @@ defmodule Mv.Authorization.Checks.HasPermission do # MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations) linked_filter_by_member_id(actor, :member_id) + "MembershipFeeCycle" -> + # MembershipFeeCycle.member_id → Member.id → User.member_id (own linked member's cycles) + linked_filter_by_member_id(actor, :member_id) + _ -> # Fallback for other resources {:filter, expr(user_id == ^actor.id)} diff --git a/lib/mv/authorization/checks/membership_fee_cycle_read_linked_for_own_data.ex b/lib/mv/authorization/checks/membership_fee_cycle_read_linked_for_own_data.ex new file mode 100644 index 0000000..092558c --- /dev/null +++ b/lib/mv/authorization/checks/membership_fee_cycle_read_linked_for_own_data.ex @@ -0,0 +1,62 @@ +defmodule Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData do + @moduledoc """ + Policy check for MembershipFeeCycle read: true only when actor has permission set "own_data" + AND record.member_id == actor.member_id. + + Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries), + while admin with member_id does not match and gets :all from HasPermission. + + - With a record (e.g. get by id): returns true only when own_data and member_id match. + - Without a record (list query): return :unknown so authorizer applies auto_filter. + """ + use Ash.Policy.Check + + alias Mv.Authorization.Checks.ActorPermissionSetIs + + @impl true + def type, do: :filter + + @impl true + def describe(_opts), + do: "own_data can read only membership_fee_cycles where member_id == actor.member_id" + + @impl true + def strict_check(actor, authorizer, _opts) do + record = get_record_from_authorizer(authorizer) + is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data") + + cond do + is_nil(record) and is_own_data -> + {:ok, :unknown} + + is_nil(record) -> + {:ok, false} + + not is_own_data -> + {:ok, false} + + record.member_id == actor.member_id -> + {:ok, true} + + true -> + {:ok, false} + end + end + + @impl true + def auto_filter(actor, _authorizer, _opts) do + if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") && + Map.get(actor, :member_id) do + [member_id: actor.member_id] + else + [] + end + end + + defp get_record_from_authorizer(authorizer) do + case authorizer.subject do + %{data: data} when not is_nil(data) -> data + _ -> nil + end + end +end diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index d1bbc3e..9a5f7a7 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -129,7 +129,7 @@ defmodule Mv.Authorization.PermissionSets do group_read_all() ++ [perm("MemberGroup", :read, :linked)] ++ membership_fee_type_read_all() ++ - membership_fee_cycle_read_all(), + [perm("MembershipFeeCycle", :read, :linked)], pages: [ # No "/" - Mitglied must not see member index at root (same content as /members). # Own profile (sidebar links to /users/:id) and own user edit diff --git a/test/mv/authorization/permission_sets_test.exs b/test/mv/authorization/permission_sets_test.exs index 9cd38fb..2f429f9 100644 --- a/test/mv/authorization/permission_sets_test.exs +++ b/test/mv/authorization/permission_sets_test.exs @@ -680,7 +680,7 @@ defmodule Mv.Authorization.PermissionSetsTest do end describe "get_permissions/1 - MembershipFeeCycle resource" do - test "all permission sets have MembershipFeeCycle read with scope :all" do + test "all permission sets have MembershipFeeCycle read; own_data uses :linked, others :all" do for set <- PermissionSets.all_permission_sets() do permissions = PermissionSets.get_permissions(set) @@ -690,8 +690,12 @@ defmodule Mv.Authorization.PermissionSetsTest do end) assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read" - assert mfc_read.scope == :all assert mfc_read.granted == true + + expected_scope = if set == :own_data, do: :linked, else: :all + + assert mfc_read.scope == expected_scope, + "Permission set #{set} should have MembershipFeeCycle read scope #{expected_scope}, got #{mfc_read.scope}" end end diff --git a/test/mv/membership_fees/membership_fee_cycle_policies_test.exs b/test/mv/membership_fees/membership_fee_cycle_policies_test.exs index 488d97d..4d0badb 100644 --- a/test/mv/membership_fees/membership_fee_cycle_policies_test.exs +++ b/test/mv/membership_fees/membership_fee_cycle_policies_test.exs @@ -2,9 +2,9 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do @moduledoc """ Tests for MembershipFeeCycle resource authorization policies. - Verifies read_only can only read (no update/mark_as_paid); - normal_user and admin can read and update (including mark_as_paid); - only admin can create and destroy. + Verifies own_data can only read :linked (linked member's cycles); + read_only can only read (no create/update/destroy); + normal_user and admin can read, create, update, destroy (including mark_as_paid). """ use Mv.DataCase, async: false @@ -69,6 +69,64 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do cycle end + describe "own_data permission set" do + setup %{actor: actor} do + user = Mv.Fixtures.user_with_role_fixture("own_data") + linked_member = create_member_fixture() + other_member = create_member_fixture() + fee_type = create_fee_type_fixture() + admin = Mv.Fixtures.user_with_role_fixture("admin") + + user = + user + |> Ash.Changeset.for_update(:update, %{}, domain: Mv.Accounts) + |> Ash.Changeset.force_change_attribute(:member_id, linked_member.id) + |> Ash.update(actor: admin, domain: Mv.Accounts) + + {:ok, user} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor) + + {:ok, cycle_linked} = + MembershipFees.create_membership_fee_cycle( + %{ + member_id: linked_member.id, + membership_fee_type_id: fee_type.id, + cycle_start: Date.utc_today(), + amount: Decimal.new("10.00"), + status: :unpaid + }, + actor: admin + ) + + {:ok, cycle_other} = + MembershipFees.create_membership_fee_cycle( + %{ + member_id: other_member.id, + membership_fee_type_id: fee_type.id, + cycle_start: Date.add(Date.utc_today(), -365), + amount: Decimal.new("10.00"), + status: :unpaid + }, + actor: admin + ) + + %{user: user, cycle_linked: cycle_linked, cycle_other: cycle_other} + end + + test "can read only linked member's cycles", %{ + user: user, + cycle_linked: cycle_linked, + cycle_other: cycle_other + } do + {:ok, list} = + Mv.MembershipFees.MembershipFeeCycle + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + ids = Enum.map(list, & &1.id) + assert cycle_linked.id in ids + refute cycle_other.id in ids + end + end + describe "read_only permission set" do setup %{actor: actor} do user = Mv.Fixtures.user_with_role_fixture("read_only")