MembershipFeeCycle: own_data read :linked via bypass and HasPermission scope
- own_data gets read scope :linked; apply_scope in HasPermission; bypass check for own_data. - PermissionSetsTest expects own_data :linked, others :all for MFC read.
This commit is contained in:
parent
890a4d3752
commit
178f5a01c7
6 changed files with 140 additions and 6 deletions
|
|
@ -84,7 +84,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
|
||||||
policies do
|
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
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
|
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
|
# MemberGroup.member_id → Member.id → User.member_id (own linked member's group associations)
|
||||||
linked_filter_by_member_id(actor, :member_id)
|
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
|
# Fallback for other resources
|
||||||
{:filter, expr(user_id == ^actor.id)}
|
{:filter, expr(user_id == ^actor.id)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -129,7 +129,7 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
group_read_all() ++
|
group_read_all() ++
|
||||||
[perm("MemberGroup", :read, :linked)] ++
|
[perm("MemberGroup", :read, :linked)] ++
|
||||||
membership_fee_type_read_all() ++
|
membership_fee_type_read_all() ++
|
||||||
membership_fee_cycle_read_all(),
|
[perm("MembershipFeeCycle", :read, :linked)],
|
||||||
pages: [
|
pages: [
|
||||||
# No "/" - Mitglied must not see member index at root (same content as /members).
|
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||||
# Own profile (sidebar links to /users/:id) and own user edit
|
# Own profile (sidebar links to /users/:id) and own user edit
|
||||||
|
|
|
||||||
|
|
@ -680,7 +680,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_permissions/1 - MembershipFeeCycle resource" do
|
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
|
for set <- PermissionSets.all_permission_sets() do
|
||||||
permissions = PermissionSets.get_permissions(set)
|
permissions = PermissionSets.get_permissions(set)
|
||||||
|
|
||||||
|
|
@ -690,8 +690,12 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
|
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
|
||||||
assert mfc_read.scope == :all
|
|
||||||
assert mfc_read.granted == true
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for MembershipFeeCycle resource authorization policies.
|
Tests for MembershipFeeCycle resource authorization policies.
|
||||||
|
|
||||||
Verifies read_only can only read (no update/mark_as_paid);
|
Verifies own_data can only read :linked (linked member's cycles);
|
||||||
normal_user and admin can read and update (including mark_as_paid);
|
read_only can only read (no create/update/destroy);
|
||||||
only admin can create and destroy.
|
normal_user and admin can read, create, update, destroy (including mark_as_paid).
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
|
@ -69,6 +69,64 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
||||||
cycle
|
cycle
|
||||||
end
|
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
|
describe "read_only permission set" do
|
||||||
setup %{actor: actor} do
|
setup %{actor: actor} do
|
||||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue