# Roles and Permissions Architecture - Technical Specification **Version:** 2.0 (Clean Rewrite) **Date:** 2025-01-13 **Status:** Ready for Implementation **Related Documents:** - [Overview](./roles-and-permissions-overview.md) - High-level concepts for stakeholders - [Implementation Plan](./roles-and-permissions-implementation-plan.md) - Step-by-step implementation guide --- ## Table of Contents - [Overview](#overview) - [Requirements Analysis](#requirements-analysis) - [Selected Architecture](#selected-architecture) - [Database Schema (MVP)](#database-schema-mvp) - [Permission System Design (MVP)](#permission-system-design-mvp) - [Resource Policies](#resource-policies) - [Page Permission System](#page-permission-system) - [UI-Level Authorization](#ui-level-authorization) - [Special Cases](#special-cases) - [User-Member Linking](#user-member-linking) - [Future: Phase 2 - Field-Level Permissions](#future-phase-2---field-level-permissions) - [Future: Phase 3 - Database-Backed Permissions](#future-phase-3---database-backed-permissions) - [Migration Strategy](#migration-strategy) - [Security Considerations](#security-considerations) - [Appendix](#appendix) --- ## Overview This document provides the complete technical specification for the **Roles and Permissions system** in the Mila membership management application. The system controls who can access what data and which actions they can perform. ### Key Design Principles 1. **Security First:** Authorization is enforced at multiple layers (database policies, page access, UI rendering) 2. **Performance:** MVP uses hardcoded permissions for < 1 microsecond checks 3. **Maintainability:** Clear separation between roles (data) and permissions (logic) 4. **Extensibility:** Clean migration path to database-backed permissions (Phase 3) 5. **User Experience:** Consistent authorization across backend and frontend 6. **Test-Driven:** All components fully tested with behavior-focused tests ### Architecture Approach **MVP (Phase 1) - Hardcoded Permission Sets:** - Permission logic in Elixir module (`Mv.Authorization.PermissionSets`) - Role data in database (`roles` table) - Roles reference permission sets by name (string) - Zero database queries for permission checks - Implementation time: 2-3 weeks **Future (Phase 2) - Field-Level Permissions:** - Extend PermissionSets with field-level granularity - Ash Calculations for read filtering - Custom Validations for write protection - No database schema changes **Future (Phase 3) - Database-Backed Permissions:** - Move permission data to database tables - Runtime permission configuration - ETS cache for performance - Migration from hardcoded module --- ## Requirements Analysis ### Core Requirements **1. Predefined Permission Sets** Four hardcoded permission sets that define access patterns: - **own_data** - User can only access their own data (default for members) - **read_only** - Read access to all member data, no modifications - **normal_user** - Create/Read/Update on members (no delete), full CRUD on custom fields - **admin** - Unrestricted access including user/role management **2. Roles Stored in Database** Five predefined roles stored in the `roles` table: - **Mitglied** (Member) → uses "own_data" permission set - **Vorstand** (Board) → uses "read_only" permission set - **Kassenwart** (Treasurer) → uses "normal_user" permission set - **Buchhaltung** (Accounting) → uses "read_only" permission set - **Admin** → uses "admin" permission set **3. Resource-Level Permissions** Control CRUD operations on: - User (credentials, profile) - Member (member data) - Property (custom field values) - PropertyType (custom field definitions) - Role (role management) **4. Page-Level Permissions** Control access to LiveView pages: - Index pages (list views) - Show pages (detail views) - Form pages (create/edit) - Admin pages **5. Granular Scopes** Three scope levels for permissions: - **:own** - Only records where `record.id == user.id` (for User resource) - **:linked** - Only records linked to user via relationships - Member: `member.user_id == user.id` - Property: `property.member.user_id == user.id` - **:all** - All records, no filtering **6. Special Cases** - **Own Credentials:** Every user can always read/update their own credentials - **Linked Member Email:** Only admins can edit email of member linked to user - **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag) - **User-Member Linking:** Only admins can link/unlink users and members **7. UI Consistency** - UI elements (buttons, links) only shown if user has permission - Page access controlled before LiveView mounts - Consistent authorization logic between backend and frontend --- ## Selected Architecture ### Approach: Hardcoded Permission Sets with Database Roles **Core Concept:** ``` PermissionSets Module (hardcoded in code) ↓ (referenced by permission_set_name) Role (stored in DB: "Vorstand" → "read_only") ↓ (assigned to user via role_id) User (each user has one role) ``` **Why This Approach?** ✅ **Fast Implementation:** 2-3 weeks vs. 4-5 weeks for DB-backed ✅ **Maximum Performance:** < 1 microsecond per check (pure function call) ✅ **Zero DB Overhead:** No permission queries, no joins, no cache needed ✅ **Git-Tracked Changes:** All permission changes in version control ✅ **Deterministic Testing:** No DB setup, purely functional tests ✅ **Clear Migration Path:** Well-defined Phase 3 for DB-backed permissions **Trade-offs:** ⚠️ **Deployment Required:** Permission changes need code deployment ⚠️ **Four Fixed Sets:** Cannot add new permission sets without code change ✔️ **Acceptable for MVP:** Requirements specify 4 fixed sets, rare changes expected ### System Architecture Diagram ``` ┌─────────────────────────────────────────────────────────────┐ │ Authorization System │ └─────────────────────────────────────────────────────────────┘ ┌──────────────────┐ │ LiveView │ │ (UI Layer) │ └────────┬─────────┘ │ │ 1. Page Access Check ↓ ┌──────────────────────────────────┐ │ CheckPagePermission Plug │ │ - Reads PermissionSets module │ │ - Matches page pattern │ │ - Redirects if unauthorized │ └────────┬─────────────────────────┘ │ │ 2. UI Element Check ↓ ┌──────────────────────────────────┐ │ MvWeb.Authorization │ │ - can?/3 │ │ - can_access_page?/2 │ │ - Uses PermissionSets module │ └────────┬─────────────────────────┘ │ │ 3. Resource Action ↓ ┌──────────────────────────────────┐ │ Ash Resource (Member, User...) │ │ - Policies block │ └────────┬─────────────────────────┘ │ │ 4. Policy Evaluation ↓ ┌──────────────────────────────────┐ │ HasPermission Policy Check │ │ - Reads actor.role │ │ - Calls PermissionSets.get_permissions/1 │ │ - Applies scope filter │ └────────┬─────────────────────────┘ │ │ 5. Permission Lookup ↓ ┌──────────────────────────────────┐ │ PermissionSets Module │ │ (Hardcoded in code) │ │ - get_permissions/1 │ │ - Returns {resources, pages} │ └──────────────────────────────────┘ ┌──────────────────────────────────┐ │ Database │ │ - roles table │ │ - users.role_id → roles.id │ └──────────────────────────────────┘ ``` **Authorization Flow:** 1. **Page Request:** Plug checks if user can access page 2. **UI Rendering:** Helper checks which buttons/links to show 3. **User Action:** Ash receives action request (create, read, update, destroy) 4. **Policy Check:** `HasPermission` evaluates permission 5. **Permission Lookup:** Reads from `PermissionSets` module (in-memory) 6. **Scope Application:** Filters query based on scope (:own, :linked, :all) 7. **Result:** Action succeeds or fails with Forbidden error --- ## Database Schema (MVP) ### Overview The MVP requires **only ONE new table**: `roles` - ✅ Stores role definitions (name, description, permission_set_name) - ✅ Links to users via foreign key - ❌ NO permission tables (permissions are hardcoded) ### Entity Relationship Diagram ``` ┌─────────────────────────────────┐ │ users │ ├─────────────────────────────────┤ │ id (PK, UUID) │ │ email │ │ hashed_password │ │ role_id (FK → roles.id) ◄───┼──┐ │ ... │ │ └─────────────────────────────────┘ │ │ │ ┌─────────────────────────────────┐ │ │ roles │ │ ├─────────────────────────────────┤ │ │ id (PK, UUID) │──┘ │ name (unique) │ │ description │ │ permission_set_name (String) │───┐ │ is_system_role (Boolean) │ │ │ inserted_at │ │ │ updated_at │ │ └─────────────────────────────────┘ │ │ │ References one of: ┌─────────────────────────────────┐ │ - "own_data" │ PermissionSets Module │◄──┘ - "read_only" │ (Hardcoded in Code) │ - "normal_user" ├─────────────────────────────────┤ - "admin" │ get_permissions(:own_data) │ │ get_permissions(:read_only) │ │ get_permissions(:normal_user) │ │ get_permissions(:admin) │ └─────────────────────────────────┘ ``` ### Table Definitions #### roles Stores role definitions that reference permission sets by name. ```sql CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL UNIQUE, description TEXT, permission_set_name VARCHAR(50) NOT NULL, is_system_role BOOLEAN NOT NULL DEFAULT false, inserted_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT check_valid_permission_set CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin')) ); CREATE UNIQUE INDEX roles_name_index ON roles (name); CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name); ``` **Fields:** - `name` - Display name (e.g., "Vorstand", "Admin") - `description` - Human-readable description - `permission_set_name` - References hardcoded permission set - `is_system_role` - If true, role cannot be deleted (protects "Mitglied") **Constraints:** - `name` must be unique - `permission_set_name` must be one of 4 valid values - System roles cannot be deleted (enforced in Ash resource) #### users (modified) Add foreign key to roles table. ```sql ALTER TABLE users ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; CREATE INDEX users_role_id_index ON users (role_id); ``` **ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it. ### Seed Data Five predefined roles created during initial setup: ```elixir # priv/repo/seeds/authorization_seeds.exs roles = [ %{ name: "Mitglied", description: "Default member role with access to own data only", permission_set_name: "own_data", is_system_role: true # Cannot be deleted! }, %{ name: "Vorstand", description: "Board member with read access to all member data", permission_set_name: "read_only", is_system_role: false }, %{ name: "Kassenwart", description: "Treasurer with full member and payment management", permission_set_name: "normal_user", is_system_role: false }, %{ name: "Buchhaltung", description: "Accounting with read-only access for auditing", permission_set_name: "read_only", is_system_role: false }, %{ name: "Admin", description: "Administrator with unrestricted access", permission_set_name: "admin", is_system_role: false } ] # Create roles with idempotent logic Enum.each(roles, fn role_data -> case Ash.get(Mv.Authorization.Role, name: role_data.name) do {:ok, existing_role} -> # Update if exists Ash.update!(existing_role, role_data) {:error, _} -> # Create if not exists Ash.create!(Mv.Authorization.Role, role_data) end end) # Assign "Mitglied" role to users without role mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied") users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id))) Enum.each(users_without_role, fn user -> Ash.update!(user, %{role_id: mitglied_role.id}) end) ``` --- ## Permission System Design (MVP) ### PermissionSets Module **Location:** `lib/mv/authorization/permission_sets.ex` This module is the **single source of truth** for all permissions in the MVP. It defines what each permission set can do. #### Module Structure ```elixir 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/properties - 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), full CRUD on properties - Cannot manage property types or users 4. **admin** - For "Admin" role - Unrestricted access to all resources - Can manage users, roles, property types ## 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 compile-time. Permission lookups are < 1 microsecond. """ @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()] } @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) ** (FunctionClauseError) no function clause matching """ @spec get_permissions(atom()) :: permission_set() def get_permissions(:own_data) do %{ resources: [ # User: Can always read/update own credentials %{resource: "User", action: :read, scope: :own, granted: true}, %{resource: "User", action: :update, scope: :own, granted: true}, # Member: Can read/update linked member %{resource: "Member", action: :read, scope: :linked, granted: true}, %{resource: "Member", action: :update, scope: :linked, granted: true}, # Property: Can read/update properties of linked member %{resource: "Property", action: :read, scope: :linked, granted: true}, %{resource: "Property", action: :update, scope: :linked, granted: true}, # PropertyType: Can read all (needed for forms) %{resource: "PropertyType", action: :read, scope: :all, granted: true} ], pages: [ "/", # Home page "/profile", # Own profile "/members/:id" # Linked member detail (filtered by policy) ] } end def get_permissions(:read_only) do %{ resources: [ # User: Can read/update own credentials only %{resource: "User", action: :read, scope: :own, granted: true}, %{resource: "User", action: :update, scope: :own, granted: true}, # Member: Can read all members, no modifications %{resource: "Member", action: :read, scope: :all, granted: true}, # Property: Can read all properties %{resource: "Property", action: :read, scope: :all, granted: true}, # PropertyType: Can read all %{resource: "PropertyType", action: :read, scope: :all, granted: true} ], pages: [ "/", "/members", # Member list "/members/:id", # Member detail "/properties", # Property overview "/profile" # Own profile ] } end def get_permissions(:normal_user) do %{ resources: [ # User: Can read/update own credentials only %{resource: "User", action: :read, scope: :own, granted: true}, %{resource: "User", action: :update, scope: :own, granted: true}, # Member: Full CRUD %{resource: "Member", action: :read, scope: :all, granted: true}, %{resource: "Member", action: :create, scope: :all, granted: true}, %{resource: "Member", action: :update, scope: :all, granted: true}, # Note: destroy intentionally omitted for safety # Property: Full CRUD %{resource: "Property", action: :read, scope: :all, granted: true}, %{resource: "Property", action: :create, scope: :all, granted: true}, %{resource: "Property", action: :update, scope: :all, granted: true}, %{resource: "Property", action: :destroy, scope: :all, granted: true}, # PropertyType: Read only (admin manages definitions) %{resource: "PropertyType", action: :read, scope: :all, granted: true} ], pages: [ "/", "/members", "/members/new", # Create member "/members/:id", "/members/:id/edit", # Edit member "/properties", "/properties/new", "/properties/:id/edit", "/profile" ] } end def get_permissions(:admin) do %{ resources: [ # User: Full management including other users %{resource: "User", action: :read, scope: :all, granted: true}, %{resource: "User", action: :create, scope: :all, granted: true}, %{resource: "User", action: :update, scope: :all, granted: true}, %{resource: "User", action: :destroy, scope: :all, granted: true}, # Member: Full CRUD %{resource: "Member", action: :read, scope: :all, granted: true}, %{resource: "Member", action: :create, scope: :all, granted: true}, %{resource: "Member", action: :update, scope: :all, granted: true}, %{resource: "Member", action: :destroy, scope: :all, granted: true}, # Property: Full CRUD %{resource: "Property", action: :read, scope: :all, granted: true}, %{resource: "Property", action: :create, scope: :all, granted: true}, %{resource: "Property", action: :update, scope: :all, granted: true}, %{resource: "Property", action: :destroy, scope: :all, granted: true}, # PropertyType: Full CRUD (admin manages custom field definitions) %{resource: "PropertyType", action: :read, scope: :all, granted: true}, %{resource: "PropertyType", action: :create, scope: :all, granted: true}, %{resource: "PropertyType", action: :update, scope: :all, granted: true}, %{resource: "PropertyType", action: :destroy, scope: :all, granted: true}, # Role: Full CRUD (admin manages roles) %{resource: "Role", action: :read, scope: :all, granted: true}, %{resource: "Role", action: :create, scope: :all, granted: true}, %{resource: "Role", action: :update, scope: :all, granted: true}, %{resource: "Role", action: :destroy, scope: :all, granted: true} ], pages: [ "*" # Wildcard: Admin can access all pages ] } 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?(String.t() | atom()) :: 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 @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 ``` #### Permission Matrix Quick reference table showing what each permission set allows: | Resource | own_data | read_only | normal_user | admin | |----------|----------|-----------|-------------|-------| | **User** (own) | R, U | R, U | R, U | R, U | | **User** (all) | - | - | - | R, C, U, D | | **Member** (linked) | R, U | - | - | - | | **Member** (all) | - | R | R, C, U | R, C, U, D | | **Property** (linked) | R, U | - | - | - | | **Property** (all) | - | R | R, C, U, D | R, C, U, D | | **PropertyType** (all) | R | R | R | R, C, U, D | | **Role** (all) | - | - | - | R, C, U, D | **Legend:** R=Read, C=Create, U=Update, D=Destroy ### HasPermission Policy Check **Location:** `lib/mv/authorization/checks/has_permission.ex` This is a custom Ash Policy Check that evaluates permissions from the `PermissionSets` module. ```elixir defmodule Mv.Authorization.Checks.HasPermission do @moduledoc """ Custom Ash Policy Check that evaluates permissions from the PermissionSets module. This check: 1. Reads the actor's role and permission_set_name 2. Looks up permissions from PermissionSets.get_permissions/1 3. Finds matching permission for current resource + action 4. Applies scope filter (:own, :linked, :all) ## Usage in Ash Resource policies do policy action_type(:read) do authorize_if Mv.Authorization.Checks.HasPermission end end ## Scope Behavior - **:all** - Authorizes without filtering (returns all records) - **:own** - Filters to records where record.id == actor.id - **:linked** - Filters based on resource type: - Member: member.user_id == actor.id - Property: property.member.user_id == actor.id (traverses relationship!) ## Error Handling Returns `{:error, reason}` for: - Missing actor - Actor without role - Invalid permission_set_name - No matching permission found All errors result in Forbidden (policy fails). """ use Ash.Policy.Check require Ash.Query import Ash.Expr alias Mv.Authorization.PermissionSets @impl true def describe(_opts) do "checks if actor has permission via their role's permission set" end @impl true def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), permissions <- PermissionSets.get_permissions(ps_atom), resource_name <- get_resource_name(resource) do check_permission(permissions.resources, resource_name, action, actor, resource_name) else %{role: nil} -> log_auth_failure(actor, resource, action, "no role assigned") {:error, :no_role} %{role: %{permission_set_name: nil}} -> log_auth_failure(actor, resource, action, "role has no permission_set_name") {:error, :no_permission_set} {:error, :invalid_permission_set} = error -> log_auth_failure(actor, resource, action, "invalid permission_set_name") error _ -> log_auth_failure(actor, resource, action, "no actor or missing data") {:error, :no_permission} end end # Extract resource name from module (e.g., Mv.Membership.Member -> "Member") defp get_resource_name(resource) when is_atom(resource) do resource |> Module.split() |> List.last() end # Find matching permission and apply scope defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do case Enum.find(resource_perms, fn perm -> perm.resource == resource_name and perm.action == action and perm.granted end) do nil -> {:error, :no_permission} perm -> apply_scope(perm.scope, actor, resource_name) end end # Scope: all - No filtering, access to all records defp apply_scope(:all, _actor, _resource) do :authorized end # Scope: own - Filter to records where record.id == actor.id # Used for User resource (users can access their own user record) defp apply_scope(:own, actor, _resource) do {:filter, expr(id == ^actor.id)} end # Scope: linked - Filter based on user_id relationship (resource-specific!) defp apply_scope(:linked, actor, resource_name) do case resource_name do "Member" -> # Member.user_id == actor.id (direct relationship) {:filter, expr(user_id == ^actor.id)} "Property" -> # Property.member.user_id == actor.id (traverse through member!) {:filter, expr(member.user_id == ^actor.id)} _ -> # Fallback for other resources: try direct user_id {:filter, expr(user_id == ^actor.id)} end end # Log authorization failures for debugging defp log_auth_failure(actor, resource, action, reason) do require Logger actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" resource_name = get_resource_name(resource) Logger.debug(""" Authorization failed: Actor: #{actor_id} Resource: #{resource_name} Action: #{action} Reason: #{reason} """) end end ``` **Key Design Decisions:** 1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id` 2. **Error Handling:** All errors log for debugging but return generic forbidden to user 3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings 4. **Pure Function:** No side effects, deterministic, easily testable --- ## Resource Policies Each Ash resource defines policies that use the `HasPermission` check. This section documents the policy structure for each resource. ### General Policy Pattern **All resources follow this pattern:** ```elixir policies do # 1. Special cases first (most specific) policy action_type(:read) do authorize_if expr(condition_for_special_case) end # 2. General authorization (uses PermissionSets) policy action_type([:read, :create, :update, :destroy]) do authorize_if Mv.Authorization.Checks.HasPermission end # 3. Default: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end ``` **Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins. ### User Resource Policies **Location:** `lib/mv/accounts/user.ex` **Special Case:** Users can ALWAYS read/update their own credentials, regardless of role. ```elixir defmodule Mv.Accounts.User do use Ash.Resource, ... policies do # SPECIAL CASE: Users can always access their own account # This takes precedence over permission checks policy action_type([:read, :update]) do description "Users can always read and update their own account" authorize_if expr(id == ^actor(:id)) end # GENERAL: Other operations require permission # (e.g., admin reading/updating other users, admin destroying users) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # DEFAULT: Forbid if no policy matched policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end # ... end ``` **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | | Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | | Read others | ❌ | ❌ | ❌ | ❌ | ✅ | | Update others | ❌ | ❌ | ❌ | ❌ | ✅ | | Create | ❌ | ❌ | ❌ | ❌ | ✅ | | Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | ### Member Resource Policies **Location:** `lib/mv/membership/member.ex` **Special Case:** Users can always access their linked member (where `member.user_id == user.id`). ```elixir defmodule Mv.Membership.Member do use Ash.Resource, ... policies do # SPECIAL CASE: Users can always access their linked member policy action_type([:read, :update]) do description "Users can access member linked to their account" authorize_if expr(user_id == ^actor(:id)) end # GENERAL: Check permissions from role policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # DEFAULT: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end # Custom validation for email editing (see Special Cases section) validations do validate changing(:email), on: :update do validate &validate_linked_member_email_change/2 end end # ... end ``` **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | | Update linked | ✅ (special)* | ❌ | ✅* | ❌ | ✅ | | Read all | ❌ | ✅ | ✅ | ✅ | ✅ | | Create | ❌ | ❌ | ✅ | ❌ | ✅ | | Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | *Email editing has additional validation (see Special Cases) ### Property Resource Policies **Location:** `lib/mv/membership/property.ex` **Special Case:** Users can access properties of their linked member. ```elixir defmodule Mv.Membership.Property do use Ash.Resource, ... policies do # SPECIAL CASE: Users can access properties of their linked member # Note: This traverses the member relationship! policy action_type([:read, :update]) do description "Users can access properties of their linked member" authorize_if expr(member.user_id == ^actor(:id)) end # GENERAL: Check permissions from role policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # DEFAULT: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end # ... end ``` **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | | Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ | | Read all | ❌ | ✅ | ✅ | ✅ | ✅ | | Create | ❌ | ❌ | ✅ | ❌ | ✅ | | Destroy | ❌ | ❌ | ✅ | ❌ | ✅ | ### PropertyType Resource Policies **Location:** `lib/mv/membership/property_type.ex` **No Special Cases:** All users can read, only admin can write. ```elixir defmodule Mv.Membership.PropertyType do use Ash.Resource, ... policies do # All authenticated users can read property types (needed for forms) # Write operations are admin-only policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # DEFAULT: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end # ... end ``` **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read | ✅ | ✅ | ✅ | ✅ | ✅ | | Create | ❌ | ❌ | ❌ | ❌ | ✅ | | Update | ❌ | ❌ | ❌ | ❌ | ✅ | | Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | ### Role Resource Policies **Location:** `lib/mv/authorization/role.ex` **Special Protection:** System roles cannot be deleted. ```elixir defmodule Mv.Authorization.Role do use Ash.Resource, ... policies do # Only admin can manage roles policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # DEFAULT: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end # Prevent deletion of system roles validations do validate action(:destroy) do validate fn _changeset, %{data: role} -> if role.is_system_role do {:error, "Cannot delete system role"} else :ok end end end end # Validate permission_set_name validations do validate attribute(:permission_set_name) do validate fn _changeset, value -> if PermissionSets.valid_permission_set?(value) do :ok else {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} end end end end # ... end ``` **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read | ❌ | ❌ | ❌ | ❌ | ✅ | | Create | ❌ | ❌ | ❌ | ❌ | ✅ | | Update | ❌ | ❌ | ❌ | ❌ | ✅ | | Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ | *Cannot destroy if `is_system_role=true` --- ## Page Permission System Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug. ### CheckPagePermission Plug **Location:** `lib/mv_web/plugs/check_page_permission.ex` This plug runs in the router pipeline and checks if the current user has permission to access the requested page. ```elixir defmodule MvWeb.Plugs.CheckPagePermission do @moduledoc """ Plug that checks if current user has permission to access the current page. ## How It Works 1. Extracts page path from conn (route template like "/members/:id") 2. Gets current user from conn.assigns 3. Gets user's permission_set_name from role 4. Calls PermissionSets.get_permissions/1 to get allowed pages 5. Matches requested path against allowed patterns 6. If unauthorized: redirects to "/" with flash error ## Pattern Matching - Exact match: "/members" == "/members" - Dynamic routes: "/members/:id" matches "/members/123" - Wildcard: "*" matches everything (admin) ## Usage in Router pipeline :require_page_permission do plug MvWeb.Plugs.CheckPagePermission end scope "/members", MvWeb do pipe_through [:browser, :require_authenticated_user, :require_page_permission] live "/", MemberLive.Index live "/:id", MemberLive.Show end """ import Plug.Conn import Phoenix.Controller alias Mv.Authorization.PermissionSets require Logger def init(opts), do: opts def call(conn, _opts) do user = conn.assigns[:current_user] page_path = get_page_path(conn) if has_page_permission?(user, page_path) do conn else log_page_access_denied(user, page_path) conn |> put_flash(:error, "You don't have permission to access this page.") |> redirect(to: "/") |> halt() end end # Extract page path from conn (route template preferred, fallback to request_path) defp get_page_path(conn) do case conn.private[:phoenix_route] do {_plug, _opts, _pipe, route_template, _meta} -> route_template _ -> conn.request_path end end # Check if user has permission for page defp has_page_permission?(nil, _page_path) do false end defp has_page_permission?(user, page_path) do with %{role: %{permission_set_name: ps_name}} <- user, {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), permissions <- PermissionSets.get_permissions(ps_atom) do page_matches?(permissions.pages, page_path) else _ -> false end end # Check if requested path matches any allowed pattern defp page_matches?(allowed_pages, requested_path) do Enum.any?(allowed_pages, fn pattern -> cond do # Wildcard: admin can access all pages pattern == "*" -> true # Exact match pattern == requested_path -> true # Dynamic route match (e.g., "/members/:id" matches "/members/123") String.contains?(pattern, ":") -> match_dynamic_route?(pattern, requested_path) # No match true -> false end end) end # Match dynamic route pattern against actual path defp match_dynamic_route?(pattern, path) do pattern_segments = String.split(pattern, "/", trim: true) path_segments = String.split(path, "/", trim: true) # Must have same number of segments if length(pattern_segments) == length(path_segments) do Enum.zip(pattern_segments, path_segments) |> Enum.all?(fn {pattern_seg, path_seg} -> # Dynamic segment (starts with :) matches anything String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg end) else false end end defp log_page_access_denied(user, page_path) do user_id = if is_map(user), do: Map.get(user, :id), else: "nil" role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil" Logger.info(""" Page access denied: User: #{user_id} Role: #{role} Page: #{page_path} """) end end ``` ### Router Integration Add plug to protected routes: ```elixir defmodule MvWeb.Router do use MvWeb, :router pipeline :require_page_permission do plug MvWeb.Plugs.CheckPagePermission end # Public routes (no authentication) scope "/", MvWeb do pipe_through :browser live "/", PageController, :home get "/login", AuthController, :new post "/login", AuthController, :create end # Protected routes (authentication + page permission) scope "/members", MvWeb do pipe_through [:browser, :require_authenticated_user, :require_page_permission] live "/", MemberLive.Index, :index live "/new", MemberLive.Form, :new live "/:id", MemberLive.Show, :show live "/:id/edit", MemberLive.Form, :edit end # Admin routes scope "/admin", MvWeb do pipe_through [:browser, :require_authenticated_user, :require_page_permission] live "/roles", RoleLive.Index, :index live "/roles/:id", RoleLive.Show, :show end end ``` ### Page Permission Examples **Mitglied (own_data):** - ✅ Can access: `/`, `/profile`, `/members/123` (if 123 is their linked member) - ❌ Cannot access: `/members`, `/members/new`, `/admin/roles` **Vorstand (read_only):** - ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile` - ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` **Kassenwart (normal_user):** - ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile` - ❌ Cannot access: `/admin/roles`, `/admin/property_types/new` **Admin:** - ✅ Can access: `*` (all pages, including `/admin/roles`) --- ## UI-Level Authorization UI-level authorization ensures that users only see buttons, links, and form fields they have permission to use. This provides a consistent user experience and prevents confusing "forbidden" errors. ### MvWeb.Authorization Helper Module **Location:** `lib/mv_web/authorization.ex` This module provides helper functions for conditional rendering in LiveView templates. ```elixir defmodule MvWeb.Authorization do @moduledoc """ UI-level authorization helpers for LiveView templates. These functions check if the current user has permission to perform actions or access pages. They use the same PermissionSets module as the backend policies, ensuring UI and backend authorization are consistent. ## Usage in Templates <%= if can?(@current_user, :create, Mv.Membership.Member) do %> <.link patch={~p"/members/new"}>New Member <% end %> <%= if can?(@current_user, :update, @member) do %> <.button>Edit <% end %> <%= if can_access_page?(@current_user, "/admin/roles") do %> <.link navigate="/admin/roles">Manage Roles <% end %> ## Performance All checks are pure function calls using the hardcoded PermissionSets module. No database queries, < 1 microsecond per check. """ alias Mv.Authorization.PermissionSets @doc """ Checks if user has permission for an action on a resource (atom). ## Examples iex> admin = %{role: %{permission_set_name: "admin"}} iex> can?(admin, :create, Mv.Membership.Member) true iex> mitglied = %{role: %{permission_set_name: "own_data"}} iex> can?(mitglied, :create, Mv.Membership.Member) false """ @spec can?(map() | nil, atom(), atom()) :: boolean() def can?(nil, _action, _resource), do: false def can?(user, action, resource) when is_atom(action) and is_atom(resource) do with %{role: %{permission_set_name: ps_name}} <- user, {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), permissions <- PermissionSets.get_permissions(ps_atom) do resource_name = get_resource_name(resource) Enum.any?(permissions.resources, fn perm -> perm.resource == resource_name and perm.action == action and perm.granted end) else _ -> false end end @doc """ Checks if user has permission for an action on a specific record (struct). Applies scope checking: - :own - record.id == user.id - :linked - record.user_id == user.id (or traverses relationships) - :all - always true ## Examples iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}} iex> member = %Member{id: "member-456", user_id: "user-123"} iex> can?(user, :update, member) true iex> other_member = %Member{id: "member-789", user_id: "other-user"} iex> can?(user, :update, other_member) false """ @spec can?(map() | nil, atom(), struct()) :: boolean() def can?(nil, _action, _record), do: false def can?(user, action, %resource{} = record) when is_atom(action) do with %{role: %{permission_set_name: ps_name}} <- user, {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), permissions <- PermissionSets.get_permissions(ps_atom) do resource_name = get_resource_name(resource) # Find matching permission matching_perm = Enum.find(permissions.resources, fn perm -> perm.resource == resource_name and perm.action == action and perm.granted end) case matching_perm do nil -> false perm -> check_scope(perm.scope, user, record, resource_name) end else _ -> false end end @doc """ Checks if user can access a specific page. ## Examples iex> admin = %{role: %{permission_set_name: "admin"}} iex> can_access_page?(admin, "/admin/roles") true iex> mitglied = %{role: %{permission_set_name: "own_data"}} iex> can_access_page?(mitglied, "/members") false """ @spec can_access_page?(map() | nil, String.t()) :: boolean() def can_access_page?(nil, _page_path), do: false def can_access_page?(user, page_path) do with %{role: %{permission_set_name: ps_name}} <- user, {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), permissions <- PermissionSets.get_permissions(ps_atom) do page_matches?(permissions.pages, page_path) else _ -> false end end # Check if scope allows access to record defp check_scope(:all, _user, _record, _resource_name), do: true defp check_scope(:own, user, record, _resource_name) do record.id == user.id end defp check_scope(:linked, user, record, resource_name) do case resource_name do "Member" -> # Direct relationship: member.user_id Map.get(record, :user_id) == user.id "Property" -> # Need to traverse: property.member.user_id # Note: In UI, property should have member preloaded case Map.get(record, :member) do %{user_id: member_user_id} -> member_user_id == user.id _ -> false end _ -> # Fallback: check user_id Map.get(record, :user_id) == user.id end end # Check if page path matches any allowed pattern defp page_matches?(allowed_pages, requested_path) do Enum.any?(allowed_pages, fn pattern -> cond do pattern == "*" -> true pattern == requested_path -> true String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path) true -> false end end) end # Match dynamic route pattern defp match_pattern?(pattern, path) do pattern_segments = String.split(pattern, "/", trim: true) path_segments = String.split(path, "/", trim: true) if length(pattern_segments) == length(path_segments) do Enum.zip(pattern_segments, path_segments) |> Enum.all?(fn {pattern_seg, path_seg} -> String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg end) else false end end # Extract resource name from module defp get_resource_name(resource) when is_atom(resource) do resource |> Module.split() |> List.last() end end ``` ### Import in mv_web.ex Make helpers available to all LiveViews: ```elixir defmodule MvWeb do # ... def html_helpers do quote do # ... existing helpers ... # Authorization helpers import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] end end # ... end ``` ### UI Examples **Navbar with conditional links:** ```heex ``` **Index page with conditional "New" button:** ```heex
| <%= member.name %> | <%= if can?(@current_user, :update, member) do %> <.link patch={~p"/members/#{member.id}/edit"}>Edit <% end %> <%= if can?(@current_user, :destroy, member) do %> <.button phx-click="delete" phx-value-id={member.id}>Delete <% end %> |