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