defmodule Mv.Authorization.PermissionSets do @moduledoc """ Defines the four hardcoded permission sets for the application. Each permission set specifies: - Resource permissions (what CRUD operations on which resources) - Page permissions (which LiveView pages can be accessed) - Scopes (own, linked, all) ## Permission Sets 1. **own_data** - Default for "Mitglied" role - Can only access own user data and linked member/custom field values - Cannot create new members or manage system 2. **read_only** - For "Vorstand" and "Buchhaltung" roles - Can read all member data - Cannot create, update, or delete 3. **normal_user** - For "Kassenwart" role - Create/Read/Update members (no delete for safety), full CRUD on custom field values - Cannot manage custom fields or users 4. **admin** - For "Admin" role - Unrestricted access to all resources - Can manage users, roles, custom fields ## Usage # Get permissions for a role's permission set permissions = PermissionSets.get_permissions(:admin) # Check if a permission set name is valid PermissionSets.valid_permission_set?("read_only") # => true # Convert string to atom safely {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data") ## Performance All functions are pure and intended to be constant-time. Permission lookups are very fast (typically < 1 microsecond in practice) as they are simple pattern matches and map lookups with no database queries or external calls. """ @type scope :: :own | :linked | :all @type action :: :read | :create | :update | :destroy @type resource_permission :: %{ resource: String.t(), action: action(), scope: scope(), granted: boolean() } @type permission_set :: %{ resources: [resource_permission()], pages: [String.t()] } # DRY helpers for shared resource permission lists (used in own_data, read_only, normal_user, admin) defp perm(resource, action, scope), do: %{resource: resource, action: action, scope: scope, granted: true} # All four CRUD actions for a resource with scope :all (used for admin) defp perm_all(resource), do: [ perm(resource, :read, :all), perm(resource, :create, :all), perm(resource, :update, :all), perm(resource, :destroy, :all) ] # User: read/update own credentials only (all non-admin sets allow password changes) defp user_own_credentials, do: [perm("User", :read, :own), perm("User", :update, :own)] defp group_read_all, do: [perm("Group", :read, :all)] defp custom_field_read_all, do: [perm("CustomField", :read, :all)] defp membership_fee_type_read_all, do: [perm("MembershipFeeType", :read, :all)] defp membership_fee_cycle_read_all, do: [perm("MembershipFeeCycle", :read, :all)] @doc """ Returns the list of all valid permission set names. ## Examples iex> PermissionSets.all_permission_sets() [:own_data, :read_only, :normal_user, :admin] """ @spec all_permission_sets() :: [atom()] def all_permission_sets do [:own_data, :read_only, :normal_user, :admin] end @doc """ Returns permissions for the given permission set. ## Examples iex> permissions = PermissionSets.get_permissions(:admin) iex> Enum.any?(permissions.resources, fn p -> ...> p.resource == "User" and p.action == :destroy ...> end) true iex> PermissionSets.get_permissions(:invalid) ** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin] """ @spec get_permissions(atom()) :: permission_set() def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do raise ArgumentError, "invalid permission set: #{inspect(set)}. Must be one of: #{inspect(all_permission_sets())}" end def get_permissions(:own_data) do %{ resources: user_own_credentials() ++ [ perm("Member", :read, :linked), perm("Member", :update, :linked), perm("CustomFieldValue", :read, :linked), perm("CustomFieldValue", :update, :linked), perm("CustomFieldValue", :create, :linked), perm("CustomFieldValue", :destroy, :linked) ] ++ custom_field_read_all() ++ group_read_all() ++ [perm("MemberGroup", :read, :linked)] ++ membership_fee_type_read_all() ++ [perm("MembershipFeeCycle", :read, :linked)], pages: [ # No "/" - Mitglied must not see member index at root (same content as /members). # Own profile (sidebar links to /users/:id) and own user edit "/users/:id", "/users/:id/edit", "/users/:id/show/edit", # Linked member detail and edit (data access filtered by policy scope: :linked) "/members/:id", "/members/:id/edit", "/members/:id/show/edit" ] } end def get_permissions(:read_only) do %{ resources: user_own_credentials() ++ [ perm("Member", :read, :all), perm("CustomFieldValue", :read, :all) ] ++ custom_field_read_all() ++ group_read_all() ++ [perm("MemberGroup", :read, :all)] ++ membership_fee_type_read_all() ++ membership_fee_cycle_read_all(), pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) "/users/:id", "/users/:id/edit", "/users/:id/show/edit", # Member list "/members", # Member detail "/members/:id", # Custom field values overview "/custom_field_values", # Custom field value detail "/custom_field_values/:id", # Groups overview "/groups", # Group detail "/groups/:slug" ] } end def get_permissions(:normal_user) do %{ resources: user_own_credentials() ++ [ perm("Member", :read, :all), perm("Member", :create, :all), perm("Member", :update, :all), # destroy intentionally omitted for safety perm("CustomFieldValue", :read, :all), perm("CustomFieldValue", :create, :all), perm("CustomFieldValue", :update, :all), perm("CustomFieldValue", :destroy, :all) ] ++ custom_field_read_all() ++ [ perm("Group", :read, :all), perm("Group", :create, :all), perm("Group", :update, :all), perm("Group", :destroy, :all) ] ++ [ perm("MemberGroup", :read, :all), perm("MemberGroup", :create, :all), perm("MemberGroup", :destroy, :all) ] ++ membership_fee_type_read_all() ++ [ perm("MembershipFeeCycle", :read, :all), perm("MembershipFeeCycle", :create, :all), perm("MembershipFeeCycle", :update, :all), perm("MembershipFeeCycle", :destroy, :all) ], pages: [ "/", # Own profile (sidebar links to /users/:id; redirect target must be allowed) "/users/:id", "/users/:id/edit", "/users/:id/show/edit", "/members", # Create member "/members/new", "/members/:id", # Edit member "/members/:id/edit", "/members/:id/show/edit", "/custom_field_values", # Custom field value detail "/custom_field_values/:id", "/custom_field_values/new", "/custom_field_values/:id/edit", # Groups overview "/groups", # Create group "/groups/new", # Group detail "/groups/:slug", # Edit group "/groups/:slug/edit" ] } end def get_permissions(:admin) do # MemberGroup has no :update action in the domain; use read/create/destroy only member_group_perms = [ perm("MemberGroup", :read, :all), perm("MemberGroup", :create, :all), perm("MemberGroup", :destroy, :all) ] %{ resources: perm_all("User") ++ perm_all("Member") ++ perm_all("CustomFieldValue") ++ perm_all("CustomField") ++ perm_all("Role") ++ perm_all("Group") ++ member_group_perms ++ perm_all("MembershipFeeType") ++ perm_all("MembershipFeeCycle"), pages: [ # Explicit admin-only pages (for clarity and future restrictions) "/settings", "/membership_fee_settings", # Wildcard: Admin can access all pages "*" ] } end def get_permissions(invalid) do raise ArgumentError, "invalid permission set: #{inspect(invalid)}. Must be one of: #{inspect(all_permission_sets())}" end @doc """ Checks if a permission set name (string or atom) is valid. ## Examples iex> PermissionSets.valid_permission_set?("admin") true iex> PermissionSets.valid_permission_set?(:read_only) true iex> PermissionSets.valid_permission_set?("invalid") false """ @spec valid_permission_set?(any()) :: boolean() def valid_permission_set?(name) when is_binary(name) do case permission_set_name_to_atom(name) do {:ok, _atom} -> true {:error, _} -> false end end def valid_permission_set?(name) when is_atom(name) do name in all_permission_sets() end def valid_permission_set?(_), do: false @doc """ Converts a permission set name string to atom safely. ## Examples iex> PermissionSets.permission_set_name_to_atom("admin") {:ok, :admin} iex> PermissionSets.permission_set_name_to_atom("invalid") {:error, :invalid_permission_set} """ @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set} def permission_set_name_to_atom(name) when is_binary(name) do atom = String.to_existing_atom(name) if valid_permission_set?(atom) do {:ok, atom} else {:error, :invalid_permission_set} end rescue ArgumentError -> {:error, :invalid_permission_set} end end