# Roles and Permissions Architecture - Technical Specification **Version:** 2.0 (Clean Rewrite) **Date:** 2025-01-13 **Last Updated:** 2026-01-13 **Status:** ✅ Implemented (2026-01-08, PR #346, closes #345) **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) - CustomFieldValue (custom field values) - CustomField (custom field definitions) - Role (role management) - Group (group definitions; read all, create/update/destroy admin only) - MemberGroup (member–group associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy) - MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy) - MembershipFeeCycle (fee cycles; all read, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin) **4. Page-Level Permissions** Control access to LiveView pages: - Index pages (list views) - Show pages (detail views) - Form pages (create/edit) - Admin pages - Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets) **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: `id == user.member_id` (User.member_id → Member.id, inverse relationship) - CustomFieldValue: `member_id == user.member_id` (traverses Member → User relationship) - **: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 - **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role. - **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages). **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/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), full CRUD on properties - 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 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}, # CustomFieldValue: Can read/update/create/destroy custom field values of linked member %{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true}, # CustomField: Can read all (needed for forms) %{resource: "CustomField", 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}, # CustomFieldValue: Can read all custom field values %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, # CustomField: Can read all %{resource: "CustomField", action: :read, scope: :all, granted: true} ], pages: [ "/", "/members", # Member list "/members/:id", # Member detail "/custom_field_values" # Custom field values 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 # CustomFieldValue: Full CRUD %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, # CustomField: Read only (admin manages definitions) %{resource: "CustomField", action: :read, scope: :all, granted: true} ], pages: [ "/", "/members", "/members/new", # Create member "/members/:id", "/members/:id/edit", # Edit member "/custom_field_values", "/custom_field_values/new", "/custom_field_values/: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}, # CustomFieldValue: Full CRUD %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :create, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, # CustomField: Full CRUD (admin manages custom field definitions) %{resource: "CustomField", action: :read, scope: :all, granted: true}, %{resource: "CustomField", action: :create, scope: :all, granted: true}, %{resource: "CustomField", action: :update, scope: :all, granted: true}, %{resource: "CustomField", 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 | | **CustomFieldValue** (linked) | R, U, C, D | - | - | - | | **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D | | **CustomField** (all) | R | R | R | R, C, U, D | | **Role** (all) | - | - | - | R, C, U, D | | **Group** (all) | R | R | R | R, C, U, D | | **MemberGroup** (linked) | R | - | - | - | | **MemberGroup** (all) | - | R | R, C, D | R, C, D | | **MembershipFeeType** (all) | R | R | R | R, C, U, D | | **MembershipFeeCycle** (all) | R | R | R, C, U, D | 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: `id == actor.member_id` (User.member_id → Member.id, inverse relationship) - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id) ## 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" -> # User.member_id → Member.id (inverse relationship) # Filter: member.id == actor.member_id {:filter, expr(id == ^actor.member_id)} "CustomFieldValue" -> # CustomFieldValue.member_id → Member.id → User.member_id # Filter: custom_field_value.member_id == actor.member_id {:filter, expr(member_id == ^actor.member_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:** CustomFieldValue 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. --- ## Bypass vs. HasPermission: When to Use Which? **Key Finding:** For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier approach**: 1. **Bypass with `expr()` for READ** - Handles list queries (auto_filter) 2. **HasPermission for UPDATE/CREATE/DESTROY** - Handles operations with records ### Why This Pattern? **The Problem with HasPermission for List Queries:** When `HasPermission` returns `{:filter, expr(...)}` for `scope :own` or `scope :linked`: - `strict_check` returns `{:ok, false}` for queries without a record - Ash does **NOT** reliably call `auto_filter` when `strict_check` returns `false` - Result: List queries fail ❌ **The Solution:** Use `bypass` with `expr()` directly for READ operations: - Ash handles `expr()` natively for both `strict_check` and `auto_filter` - List queries work correctly ✅ - Single-record reads work correctly ✅ ### Pattern Summary | Operation | Has Record? | Use | Why | |-----------|-------------|-----|-----| | **READ (list)** | ❌ No | `bypass` with `expr()` | Triggers auto_filter | | **READ (single)** | ✅ Yes | `bypass` with `expr()` | expr() evaluates to true/false | | **UPDATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record | | **CREATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record | | **DESTROY** | ✅ Yes | `HasPermission` | strict_check can evaluate record | ### Is scope :own/:linked Still Useful? **YES! ✅** The scope concept is essential: 1. **Documentation** - Clearly expresses intent in PermissionSets 2. **UPDATE/CREATE/DESTROY** - Works perfectly via HasPermission when record is present 3. **Consistency** - All permissions are centralized in PermissionSets 4. **Maintainability** - Easy to see what each role can do The bypass is a **technical workaround** for Ash's auto_filter limitation, not a replacement for the scope concept. ### Consistency Across Resources Both `User` and `Member` follow this pattern: - **User**: Bypass for READ (`id == ^actor(:id)`), HasPermission for UPDATE (`scope :own`) - **Member**: Bypass for READ (`id == ^actor(:member_id)`), HasPermission for UPDATE (`scope :linked`) This ensures consistent behavior and predictable authorization logic throughout the application. --- ### User Resource Policies **Location:** `lib/accounts/user.ex` **Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own). **Key Insight:** Bypass with `expr()` is needed ONLY for READ list queries because HasPermission's strict_check cannot properly trigger auto_filter. UPDATE operations work correctly via HasPermission because a changeset with record is available. ```elixir defmodule Mv.Accounts.User do use Ash.Resource, ... policies do # 1. AshAuthentication Bypass (registration/login without actor) bypass AshAuthentication.Checks.AshAuthenticationInteraction do authorize_if always() end # 2. SPECIAL CASE: Users can always READ their own account # Bypass needed for list queries (expr() triggers auto_filter in Ash) # UPDATE is handled by HasPermission below (scope :own works with changesets) bypass action_type(:read) do description "Users can always read their own account" authorize_if expr(id == ^actor(:id)) end # 3. GENERAL: Check permissions from user's role # - :own_data → can UPDATE own user (scope :own via HasPermission) # - :read_only → can UPDATE own user (scope :own via HasPermission) # - :normal_user → can UPDATE own user (scope :own via HasPermission) # - :admin → can read/create/update/destroy all users (scope :all) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role and permission set" authorize_if Mv.Authorization.Checks.HasPermission end # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # ... end ``` **Why Bypass for READ but not UPDATE?** - **READ list queries** (`Ash.read(User, actor: user)`): No record at strict_check time → HasPermission returns `{:ok, false}` → auto_filter not called → bypass with `expr()` needed ✅ - **UPDATE operations** (`Ash.update(changeset, actor: user)`): Changeset contains record → HasPermission can evaluate `scope :own` correctly → works via HasPermission ✅ **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read own | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (scope :all) | | Update own | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :all) | | Read others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) | | Update others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) | | Create | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) | | Destroy | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) | **Note:** This pattern is consistent with Member resource policies (bypass for READ, HasPermission for UPDATE). ### Member Resource Policies **Location:** `lib/mv/membership/member.ex` **Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked). **Key Insight:** Same pattern as User - bypass with `expr()` is needed ONLY for READ list queries. UPDATE operations work correctly via HasPermission because a changeset with record is available. ```elixir defmodule Mv.Membership.Member do use Ash.Resource, ... policies do # 1. SPECIAL CASE: Users can always READ their linked member # Bypass needed for list queries (expr() triggers auto_filter in Ash) # UPDATE is handled by HasPermission below (scope :linked works with changesets) bypass action_type(:read) do description "Users can always read member linked to their account" authorize_if expr(id == ^actor(:member_id)) end # 2. GENERAL: Check permissions from role # - :own_data → can UPDATE linked member (scope :linked via HasPermission) # - :read_only → can READ all members (scope :all), no update permission # - :normal_user → can CRUD all members (scope :all) # - :admin → can CRUD all members (scope :all) policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) 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 ``` **Why Bypass for READ but not UPDATE?** - **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅ - **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅ **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) ### CustomFieldValue Resource Policies **Location:** `lib/membership/custom_field_value.ex` **Pattern:** Bypass for READ (list queries), CustomFieldValueCreateScope for create (no filter), HasPermission for read/update/destroy. Create uses a dedicated check because Ash cannot apply filters to create actions. The bypass `action_type(:read)` is a production-side rule: reading own CFVs (where `member_id == actor.member_id`) is always allowed and overrides Permission-Sets; no further policies are needed for that. It applies to all read actions (get, list, load). ```elixir defmodule Mv.Membership.CustomFieldValue do use Ash.Resource, ... policies do # Bypass for READ (list queries; expr triggers auto_filter) bypass action_type(:read) do description "Users can read custom field values of their linked member" authorize_if expr(member_id == ^actor(:member_id)) end # CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create) # own_data -> create when member_id == actor.member_id; normal_user/admin -> create (scope :all) policy action_type(:create) do authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope end # READ/UPDATE/DESTROY: HasPermission (scope :linked / :all) policy action_type([:read, :update, :destroy]) do authorize_if Mv.Authorization.Checks.HasPermission end # DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed) end end ``` **Permission Matrix:** | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| | Read linked | ✅ (bypass) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | | Update linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ | | Create linked | ✅ (CustomFieldValueCreateScope) | ❌ | ✅ | ❌ | ✅ | | Destroy linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ | | Read all | ❌ | ✅ | ✅ | ✅ | ✅ | | Create all | ❌ | ❌ | ✅ | ❌ | ✅ | | Destroy all | ❌ | ❌ | ✅ | ❌ | ✅ | ### CustomField Resource Policies **Location:** `lib/membership/custom_field.ex` **No Special Cases:** All users can read, only admin can write. ```elixir defmodule Mv.Membership.CustomField do use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer, authorizers: [Ash.Policy.Authorizer] policies do policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission 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` ### User Role Assignment (Admin-Only) **Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex` Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern. ### Group Resource Policies **Location:** `lib/membership/group.ex` Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy. No bypass (scope :all only in PermissionSets). ### MemberGroup Resource Policies **Location:** `lib/membership/member_group.ex` Bypass for read with `expr(member_id == ^actor(:member_id))` (own_data list); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). HasPermission applies `:linked` scope for MemberGroup (see HasPermission apply_scope). ### MembershipFeeType Resource Policies **Location:** `lib/membership_fees/membership_fee_type.ex` Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy. ### MembershipFeeCycle Resource Policies **Location:** `lib/membership_fees/membership_fee_cycle.ex` Policies use `HasPermission` for read/create/update/destroy. All can read; read_only cannot update/create/destroy; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles" in the member detail view; UI button is shown when `can_create_cycle`). --- ## 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`, `/custom_field_values`, `/profile` - ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` **Kassenwart (normal_user):** - ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/profile` - ❌ Cannot access: `/admin/roles`, `/admin/custom_fields/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 "CustomFieldValue" -> # Need to traverse: custom_field_value.member.user_id # Note: In UI, custom_field_value 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 %> |