User: fix last-admin validation and forbid non-admin role_id change

- Last-admin only when target role is non-admin (admins may switch admin roles).
- Use Ash.Changeset.get_attribute for new role_id. Tests: admin role switch, non-admin update_user role_id forbidden.
This commit is contained in:
Moritz 2026-02-04 09:19:47 +01:00
parent dbd0a57292
commit 67ce514ba0
2 changed files with 75 additions and 23 deletions

View file

@ -391,18 +391,29 @@ defmodule Mv.Accounts.User do
end end
end end
# Last-admin: prevent the only admin from changing their role (at least one admin required). # Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
# Only block when the user is leaving admin (target role is not admin). Switching between
# two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
validate fn changeset, _context -> validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :role_id) do if Ash.Changeset.changing_attribute?(changeset, :role_id) do
new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)
if is_nil(new_role_id) do
:ok
else
current_role_id = changeset.data.role_id current_role_id = changeset.data.role_id
current_role = current_role =
Mv.Authorization.Role Mv.Authorization.Role
|> Ash.get!(current_role_id, authorize?: false) |> Ash.get!(current_role_id, authorize?: false)
if current_role.permission_set_name != "admin" do new_role =
:ok Mv.Authorization.Role
else |> Ash.get!(new_role_id, authorize?: false)
# Only block when current user is admin and target role is not admin (leaving admin)
if current_role.permission_set_name == "admin" and
new_role.permission_set_name != "admin" do
admin_role_ids = admin_role_ids =
Mv.Authorization.Role Mv.Authorization.Role
|> Ash.Query.for_read(:read) |> Ash.Query.for_read(:read)
@ -426,6 +437,9 @@ defmodule Mv.Accounts.User do
else else
:ok :ok
end end
else
:ok
end
end end
else else
:ok :ok

View file

@ -105,6 +105,19 @@ defmodule Mv.Accounts.UserPoliciesTest do
Ash.destroy!(user, actor: user) Ash.destroy!(user, actor: user)
end end
end end
@tag permission_set: permission_set
test "cannot change role via update_user - forbidden (#{permission_set})", %{
user: user,
other_user: other_user
} do
other_role = Mv.Fixtures.role_fixture("read_only")
assert {:error, %Ash.Error.Forbidden{}} =
other_user
|> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id})
|> Ash.update(actor: user, domain: Mv.Accounts)
end
end end
end end
@ -221,6 +234,31 @@ defmodule Mv.Accounts.UserPoliciesTest do
end), end),
"Expected last-admin validation message, got: #{inspect(error_messages)}" "Expected last-admin validation message, got: #{inspect(error_messages)}"
end end
test "admin can switch to another admin role (two roles with permission_set_name admin)", %{
actor: _actor
} do
# Two distinct roles both with permission_set_name "admin" (e.g. "Admin" and "Superadmin")
admin_role_a = Mv.Fixtures.role_fixture("admin")
admin_role_b = Mv.Fixtures.role_fixture("admin")
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
# Ensure user has role_a so we can switch to role_b
{:ok, admin_user} =
admin_user
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_a.id})
|> Ash.update(actor: admin_user)
assert admin_user.role_id == admin_role_a.id
# Switching to another admin role must be allowed (no last-admin error)
{:ok, updated} =
admin_user
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_b.id})
|> Ash.update(actor: admin_user)
assert updated.role_id == admin_role_b.id
end
end end
describe "AshAuthentication bypass" do describe "AshAuthentication bypass" do