Restrict member user link to admins (forbid policy)

Add ForbidMemberUserLinkUnlessAdmin check; forbid_if on Member create/update.
Fix member user-link tests: pass :user in params, assert via reload.
This commit is contained in:
Moritz 2026-02-04 12:50:10 +01:00
parent 4d3a64c177
commit 26fbafdd9d
Signed by: moritz
GPG key ID: 1020A035E5DD0824
3 changed files with 186 additions and 7 deletions

View file

@ -403,4 +403,120 @@ defmodule Mv.Membership.MemberPoliciesTest do
assert updated_member.first_name == "Updated"
end
end
describe "member user link - only admin may set or change user link" do
test "normal_user can create member without :user argument", %{actor: _actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user)
{:ok, member} =
Membership.create_member(
%{
first_name: "NoLink",
last_name: "Member",
email: "nolink#{System.unique_integer([:positive])}@example.com"
},
actor: normal_user
)
assert member.first_name == "NoLink"
# Member has_one :user (FK on User side); ensure no user is linked
{:ok, member} = Ash.load(member, :user, domain: Mv.Membership)
assert is_nil(member.user)
end
test "normal_user cannot create member with :user argument (forbidden)", %{actor: _actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user)
# Another user to try to link to
other_user = Mv.Fixtures.user_with_role_fixture("read_only")
other_user = Mv.Authorization.Actor.ensure_loaded(other_user)
attrs = %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com",
user: %{id: other_user.id}
}
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member(attrs, actor: normal_user)
end
test "normal_user can update member without :user argument", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user)
unlinked_member = create_unlinked_member(actor)
{:ok, updated} =
Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"},
actor: normal_user
)
assert updated.first_name == "UpdatedByNormal"
end
test "normal_user cannot update member with :user argument (forbidden)", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user)
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
other_user = Mv.Authorization.Actor.ensure_loaded(other_user)
unlinked_member = create_unlinked_member(actor)
# Passing :user in params tries to link member to other_user - only admin may do that
params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}}
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(unlinked_member, params, actor: normal_user)
end
test "admin can create member with :user argument", %{actor: _actor} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
link_target = Mv.Fixtures.user_with_role_fixture("own_data")
link_target = Mv.Authorization.Actor.ensure_loaded(link_target)
attrs = %{
first_name: "AdminLinked",
last_name: "Member",
email: "adminlinked#{System.unique_integer([:positive])}@example.com",
user: %{id: link_target.id}
}
{:ok, member} = Membership.create_member(attrs, actor: admin)
assert member.first_name == "AdminLinked"
# Reload link_target to see the new member_id set by manage_relationship
{:ok, link_target} =
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
assert link_target.member_id == member.id
end
test "admin can update member with :user argument (link)", %{actor: actor} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
unlinked_member = create_unlinked_member(actor)
link_target = Mv.Fixtures.user_with_role_fixture("read_only")
link_target = Mv.Authorization.Actor.ensure_loaded(link_target)
{:ok, updated} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
assert updated.id == unlinked_member.id
# Member should now be linked to link_target (user.member_id points to this member)
{:ok, reloaded_user} =
Ash.get(Mv.Accounts.User, link_target.id,
domain: Mv.Accounts,
load: [:member],
actor: admin
)
assert reloaded_user.member_id == updated.id
end
end
end