mitgliederverwaltung/lib/mv/authorization/role.ex
Moritz e47547e065 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
2026-02-04 12:37:48 +01:00

184 lines
5.3 KiB
Elixir

defmodule Mv.Authorization.Role do
@moduledoc """
Represents a user role that references a permission set.
Roles are stored in the database and link users to permission sets.
Each role has a `permission_set_name` that references one of the four
hardcoded permission sets defined in `Mv.Authorization.PermissionSets`.
## Fields
- `name` - Unique role name (e.g., "Vorstand", "Admin")
- `description` - Human-readable description of the role
- `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin"
- `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied")
## Relationships
- `has_many :users` - Users assigned to this role
## Validations
- `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0)
- `name` must be unique
- System roles cannot be deleted (enforced via validation)
## Examples
# Create a new role
{:ok, role} = Mv.Authorization.create_role(%{
name: "Vorstand",
description: "Board member with read access",
permission_set_name: "read_only"
})
# List all roles
{:ok, roles} = Mv.Authorization.list_roles()
"""
use Ash.Resource,
domain: Mv.Authorization,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "roles"
repo Mv.Repo
references do
# Prevent deletion of roles that are assigned to users
reference :users, on_delete: :restrict
end
end
code_interface do
define :create_role
define :list_roles, action: :read
define :update_role
define :destroy_role, action: :destroy
end
actions do
defaults [:read]
create :create_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
accept [:name, :description, :permission_set_name]
# Note: In Ash 3.0, require_atomic? is not available for create actions
# Custom validations will still work
end
create :create_role_with_system_flag do
description "Internal action to create roles, allowing `is_system_role` to be set. Used by seeds and migrations."
accept [:name, :description, :permission_set_name, :is_system_role]
end
update :update_role do
primary? true
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
accept [:name, :description, :permission_set_name]
# Required because custom validation functions cannot be executed atomically
require_atomic? false
end
destroy :destroy do
# Required because custom validation functions cannot be executed atomically
require_atomic? false
end
end
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Role access: read for all permission sets, create/update/destroy for admin only (PermissionSets)"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
validate one_of(
:permission_set_name,
Mv.Authorization.PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1)
),
message:
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
validate fn changeset, _context ->
if changeset.data.is_system_role do
{:error,
field: :is_system_role,
message:
"Cannot delete system role. System roles are required for the application to function."}
else
:ok
end
end,
on: [:destroy]
end
attributes do
uuid_v7_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
attribute :permission_set_name, :string do
allow_nil? false
public? true
end
attribute :is_system_role, :boolean do
allow_nil? false
default false
public? true
end
timestamps()
end
relationships do
has_many :users, Mv.Accounts.User do
destination_attribute :role_id
end
end
identities do
identity :unique_name, [:name]
end
@doc """
Loads the "Mitglied" role without authorization (for bootstrap operations).
This is a helper function to avoid code duplication when loading the default
role in changes, migrations, and test setup.
## Returns
- `{:ok, %Mv.Authorization.Role{}}` - The "Mitglied" role
- `{:ok, nil}` - Role doesn't exist
- `{:error, term()}` - Error during lookup
## Examples
{:ok, mitglied_role} = Mv.Authorization.Role.get_mitglied_role()
# => {:ok, %Mv.Authorization.Role{name: "Mitglied", ...}}
{:ok, nil} = Mv.Authorization.Role.get_mitglied_role()
# => Role doesn't exist (e.g., in test environment before seeds run)
"""
@spec get_mitglied_role() :: {:ok, t() | nil} | {:error, term()}
def get_mitglied_role do
require Ash.Query
__MODULE__
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
end
end