Add Role resource policies (defense-in-depth)

- PermissionSets: Role read :all for own_data, read_only, normal_user; admin keeps full CRUD
- Role resource: authorizers and policies with HasPermission
- Tests: role_policies_test.exs (read all, create/update/destroy admin only)
- Fix existing tests to pass actor or authorize?: false for Role operations
This commit is contained in:
Moritz 2026-02-04 12:37:48 +01:00
parent 10f37a1246
commit 4d3a64c177
Signed by: moritz
GPG key ID: 1020A035E5DD0824
8 changed files with 304 additions and 51 deletions

View file

@ -0,0 +1,226 @@
defmodule Mv.Authorization.RolePoliciesTest do
@moduledoc """
Tests for Role resource authorization policies.
Rule: All permission sets (own_data, read_only, normal_user, admin) can **read** roles.
Only **admin** can create, update, or destroy roles.
"""
use Mv.DataCase, async: false
alias Mv.Authorization
alias Mv.Authorization.Role
describe "read access - all permission sets can read roles" do
setup do
# Create a role to read (via system_actor; once policies exist, system_actor is admin)
role = Mv.Fixtures.role_fixture("read_only")
%{role: role}
end
@tag :permission_set_own_data
test "own_data can list roles", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, roles} = Authorization.list_roles(actor: user)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_own_data
test "own_data can get role by id", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
assert loaded.id == role.id
end
@tag :permission_set_read_only
test "read_only can list roles", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, roles} = Authorization.list_roles(actor: user)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_read_only
test "read_only can get role by id", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
assert loaded.id == role.id
end
@tag :permission_set_normal_user
test "normal_user can list roles", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, roles} = Authorization.list_roles(actor: user)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_normal_user
test "normal_user can get role by id", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
assert loaded.id == role.id
end
@tag :permission_set_admin
test "admin can list roles", %{role: _role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
{:ok, roles} = Authorization.list_roles(actor: admin)
assert is_list(roles)
assert roles != []
end
@tag :permission_set_admin
test "admin can get role by id", %{role: role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
{:ok, loaded} = Ash.get(Role, role.id, actor: admin, domain: Mv.Authorization)
assert loaded.id == role.id
end
end
describe "create/update/destroy - only admin allowed" do
setup do
# Non-system role for destroy test (role_fixture creates non-system roles)
role = Mv.Fixtures.role_fixture("normal_user")
%{role: role}
end
test "admin can create_role", %{role: _role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "read_only"
}
assert {:ok, _created} = Authorization.create_role(attrs, actor: admin)
end
test "admin can update_role", %{role: role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
assert {:ok, updated} =
Authorization.update_role(role, %{description: "Updated by admin"}, actor: admin)
assert updated.description == "Updated by admin"
end
test "admin can destroy non-system role", %{role: role} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
admin = Mv.Authorization.Actor.ensure_loaded(admin)
assert :ok = Authorization.destroy_role(role, actor: admin)
end
test "own_data cannot create_role (forbidden)", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "read_only"
}
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
end
test "own_data cannot update_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} =
Authorization.update_role(role, %{description: "Updated"}, actor: user)
end
test "own_data cannot destroy_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
end
test "read_only cannot create_role (forbidden)", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "read_only"
}
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
end
test "read_only cannot update_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} =
Authorization.update_role(role, %{description: "Updated"}, actor: user)
end
test "read_only cannot destroy_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
end
test "normal_user cannot create_role (forbidden)", %{role: _role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
attrs = %{
name: "New Role #{System.unique_integer([:positive])}",
description: "Test",
permission_set_name: "normal_user"
}
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
end
test "normal_user cannot update_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} =
Authorization.update_role(role, %{description: "Updated"}, actor: user)
end
test "normal_user cannot destroy_role (forbidden)", %{role: role} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
user = Mv.Authorization.Actor.ensure_loaded(user)
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
end
end
end

View file

@ -12,27 +12,29 @@ defmodule Mv.Authorization.RoleTest do
end
describe "permission_set_name validation" do
test "accepts valid permission set names" do
test "accepts valid permission set names", %{actor: actor} do
attrs = %{
name: "Test Role",
permission_set_name: "own_data"
}
assert {:ok, role} = Authorization.create_role(attrs)
assert {:ok, role} = Authorization.create_role(attrs, actor: actor)
assert role.permission_set_name == "own_data"
end
test "rejects invalid permission set names" do
test "rejects invalid permission set names", %{actor: actor} do
attrs = %{
name: "Test Role",
permission_set_name: "invalid_set"
}
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.create_role(attrs, actor: actor)
assert error_message(errors, :permission_set_name) =~ "must be one of"
end
test "accepts all four valid permission sets" do
test "accepts all four valid permission sets", %{actor: actor} do
valid_sets = ["own_data", "read_only", "normal_user", "admin"]
for permission_set <- valid_sets do
@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do
permission_set_name: permission_set
}
assert {:ok, _role} = Authorization.create_role(attrs)
assert {:ok, _role} = Authorization.create_role(attrs, actor: actor)
end
end
end
@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do
{:ok, system_role} = Ash.create(changeset, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.destroy_role(system_role)
Authorization.destroy_role(system_role, actor: actor)
message = error_message(errors, :is_system_role)
assert message =~ "Cannot delete system role"
end
test "allows deletion of non-system roles" do
test "allows deletion of non-system roles", %{actor: actor} do
# is_system_role defaults to false, so regular create works
{:ok, regular_role} =
Authorization.create_role(%{
name: "Regular Role",
permission_set_name: "read_only"
})
Authorization.create_role(
%{name: "Regular Role", permission_set_name: "read_only"},
actor: actor
)
assert :ok = Authorization.destroy_role(regular_role)
assert :ok = Authorization.destroy_role(regular_role, actor: actor)
end
end
describe "name uniqueness" do
test "enforces unique role names" do
test "enforces unique role names", %{actor: actor} do
attrs = %{
name: "Unique Role",
permission_set_name: "own_data"
}
assert {:ok, _} = Authorization.create_role(attrs)
assert {:ok, _} = Authorization.create_role(attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Authorization.create_role(attrs, actor: actor)
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
assert error_message(errors, :name) =~ "has already been taken"
end
end

View file

@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
end
# Helper function to ensure admin role exists
# Helper function to ensure admin role exists (bootstrap: no actor yet, use authorize?: false)
defp ensure_admin_role do
case Authorization.list_roles() do
case Authorization.list_roles(authorize?: false) do
{:ok, roles} ->
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
nil ->
{:ok, role} =
Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
})
Authorization.create_role(
%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
},
authorize?: false
)
role
@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do
_ ->
{:ok, role} =
Authorization.create_role(%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
})
Authorization.create_role(
%{
name: "Admin",
description: "Administrator with full access",
permission_set_name: "admin"
},
authorize?: false
)
role
end
@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do
test "raises error if system user has wrong role", %{system_user: system_user} do
# Create a non-admin role (using read_only as it's a valid permission set)
system_actor = SystemActor.get_system_actor()
{:ok, read_only_role} =
Authorization.create_role(%{
name: "Read Only Role",
description: "Read-only access",
permission_set_name: "read_only"
})
Authorization.create_role(
%{
name: "Read Only Role",
description: "Read-only access",
permission_set_name: "read_only"
},
actor: system_actor
)
system_actor = SystemActor.get_system_actor()