# 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 normal_user and admin) - 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; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin) - JoinRequest (membership join requests; normal_user read+update, admin full CRUD) **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 administrators or the linked user can change the email for members linked to users (see `Mv.Membership.Member.Validations.EmailChangePermission`) - **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 (2-3 weeks vs. 4-5 for DB-backed), maximum performance (< 1μs per check, no permission queries/joins/cache), Git-tracked permission changes, deterministic functional tests, and a well-defined Phase 3 migration path. **Trade-offs:** Permission changes need a code deployment and new sets cannot be added without a code change — acceptable for the MVP, which specifies 4 fixed sets with rare changes. ### 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. The SQL below is **illustrative** — see `priv/repo/migrations/*_add_authorization_domain.exs` for the exact DDL (notably the primary key uses the custom `uuid_generate_v7()` SQL function, and the unique index is named `roles_unique_name_index`). ```sql CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), 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_unique_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 The five predefined roles are seeded in `priv/repo/seeds_bootstrap.exs` (the canonical source — do not duplicate the data here). Each role is created idempotently via the Role resource's `:create_role_with_system_flag` action, and the seeds map roles to permission sets as: | Role | permission_set_name | is_system_role | |------|---------------------|----------------| | Mitglied | own_data | true (cannot be deleted) | | Vorstand | read_only | false | | Kassenwart | normal_user | false | | Buchhaltung | read_only | false | | Admin | admin | false | Assigning the default "Mitglied" role to users that have no role is handled separately by the `assign_mitglied_role_to_existing_users` migration, not by the seed script. --- ## 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, as pure compile-time functions (lookups < 1μs). **Types & public API:** - `@type scope :: :own | :linked | :all`, `@type action :: :read | :create | :update | :destroy` - A `resource_permission` is `%{resource: String.t(), action:, scope:, granted: boolean}`; a `permission_set` is `%{resources: [resource_permission], pages: [String.t()]}`. - `all_permission_sets/0` → `[:own_data, :read_only, :normal_user, :admin]`. - `get_permissions/1` — one function clause per set returning its `%{resources, pages}` map. An unknown atom raises `ArgumentError` (callers always go through the conversion below). - `valid_permission_set?/1` — accepts string or atom; the string clause delegates to the converter; the atom clause checks membership in `all_permission_sets/0`. - `permission_set_name_to_atom/1` — `String.to_existing_atom/1` guarded by validity, and **rescues `ArgumentError`** (unknown string → never-created atom) returning `{:error, :invalid_permission_set}`. This is the safe entry point used everywhere. **Resource permissions per set** are exactly the Permission Matrix below. Note `normal_user` intentionally omits `Member :destroy` (safety); `own_data` has full CRUD on its linked CustomFieldValues; all four sets grant `User read/update :own`. **Pages per set:** the exact `pages` lists live in the `get_permissions/1` clauses of `Mv.Authorization.PermissionSets` (single source of truth). Key facts that shape the lists: - **own_data:** deliberately does **not** include `/` (Mitglied must not see the member index at root, which has the same content as `/members`). Self-service pages are `/users/:id`, `/users/:id/edit`, `/users/:id/show/edit`; linked-member pages are `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit` (data access filtered by policy scope `:linked`). - **read_only / normal_user:** include `/` plus the self-service `/users/:id…` pages and their respective member / custom-field-value / group pages; normal_user additionally has the create/edit pages and the `/join_requests` approval pages. - **admin:** `"*"` wildcard (all pages), with `/settings` and `/membership_fee_settings` also listed explicitly. There is no `/profile` route; the self-service profile pages are the `/users/:id…` routes above. #### 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, C, U, D | 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** (linked) | R | - | - | - | | **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D | | **JoinRequest** (all) | - | - | R, U | 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 # ... end ``` **`match?/3` logic.** With-chain: read `actor.role.permission_set_name` (non-nil) → `PermissionSets.permission_set_name_to_atom/1` → `get_permissions/1` → resource name via `Module.split() |> List.last()`. Find the granted permission matching resource+action; if none, `{:error, :no_permission}`; otherwise apply the scope filter. The `else` clauses log and return a specific reason — `{:error, :no_role}` (role nil), `{:error, :no_permission_set}` (permission_set_name nil), `{:error, :invalid_permission_set}`, or `{:error, :no_permission}` (no actor/missing data). Every error results in Forbidden (fail-closed). **Scope filters (`apply_scope/3`):** - `:all` → `:authorized` (no filter) - `:own` → `{:filter, expr(id == ^actor.id)}` (User: own record) - `:linked` → resource-specific: - `"Member"` → `{:filter, expr(id == ^actor.member_id)}` (User.member_id → Member.id, inverse) - `"CustomFieldValue"` → `{:filter, expr(member_id == ^actor.member_id)}` (traverses CFV.member_id → Member → User.member_id) - fallback → `{:filter, expr(user_id == ^actor.id)}` **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 For filter-based permissions (`scope :own`, `scope :linked`) the resources use a two-tier pattern: **bypass with `expr()` for READ** (Ash does not reliably trigger `auto_filter` when `HasPermission`'s `strict_check` returns `{:ok, false}` on record-less list queries), and **HasPermission for UPDATE/CREATE/DESTROY** (a changeset record is present, so scope is evaluated correctly). The scope concept stays meaningful — bypass is only a workaround for Ash's auto_filter limitation, not a replacement for it. The full rationale, the per-operation decision table, and why both `User` and `Member` follow this pattern are documented in the canonical [policy-bypass-vs-haspermission.md](./policy-bypass-vs-haspermission.md). --- ### 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/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. READ/DESTROY: Check permissions only (no :user argument on these actions) policy action_type([:read, :destroy]) do description "Check permissions from user's role" authorize_if Mv.Authorization.Checks.HasPermission end # 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all policy action_type([:create, :update]) do description "Forbid user link unless admin; then check permissions" forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin authorize_if Mv.Authorization.Checks.HasPermission end # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed) end # Linked-member email editing is enforced by a dedicated Validations module # (see Special Cases section) validations do validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update] 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 ✅ **User–member link:** Only admins may pass the `:user` argument on create_member or update_member (link or unlink via `user: nil`/`user: %{}`). The check uses **argument presence** (key in arguments), not value, to avoid bypass (see [User-Member Linking](#user-member-linking)). **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` **Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary. **Special Protection:** System roles cannot be deleted (validation on destroy). ```elixir defmodule Mv.Authorization.Role do use Ash.Resource, authorizers: [Ash.Policy.Authorizer] policies do policy action_type([:read, :create, :update, :destroy]) do description "Check permissions from user's role (read all, create/update/destroy admin only)" authorize_if Mv.Authorization.Checks.HasPermission 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; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets). ### MemberGroup Resource Policies **Location:** `lib/membership/member_group.ex` Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply). ### 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` Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side. --- ## 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, **before** the LiveView mounts. **Behavior (`lib/mv_web/plugs/check_page_permission.ex`):** 1. Extracts the page path as the **route template** (e.g. `/members/:id`) via `Phoenix.Router.route_info/4`, falling back to `conn.request_path`. Using the template, not the concrete path, is what lets the permission `pages` lists stay parameterized. (Public paths such as `/sign-in`, `/register`, `/auth/*` are exempt and pass through.) 2. Reads `current_user` from `conn.assigns`, resolves its `permission_set_name`, and looks up the allowed `pages` via `PermissionSets.get_permissions/1`. 3. Matches the path against allowed patterns: `*` wildcard (admin), exact match, or segment-wise dynamic match where a `:`-prefixed pattern segment matches any path segment (same segment count required). 4. On no match (including nil user, no role, or invalid permission set → false): logs the denial and **redirects to `/users/:id`** (the logged-in user's own profile) or, when there is no user, to **`/sign-in`**, then halts. The `"You don't have permission to access this page."` flash is set only for a logged-in user; an unauthenticated visitor is redirected without a flash. ### 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: `/users/123` (own profile), `/members/123` (if 123 is their linked member) - ❌ Cannot access: `/` (root member index is excluded for own_data), `/members`, `/members/new`, `/settings` **Vorstand (read_only):** - ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/users/123` (own profile) - ❌ Cannot access: `/members/new`, `/members/123/edit`, `/settings` **Kassenwart (normal_user):** - ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/join_requests`, `/users/123` (own profile) - ❌ Cannot access: `/settings`, `/membership_fee_settings` **Admin:** - ✅ Can access: `*` (all pages, including `/settings` and `/membership_fee_settings`) --- ## 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, reading from the **same** `PermissionSets` module as the backend policies so UI and backend stay consistent (pure function calls, no DB queries). Imported into `mv_web.ex` `html_helpers` so every LiveView has it: `import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]`. **Public functions:** - `can?/3` (resource atom) — `can?(user, action, Mv.Membership.Member)`: true iff the user's permission set grants `action` on that resource (any scope). - `can?/3` (record struct) — `can?(user, action, %Member{})`: finds the matching permission, then applies the scope check against the record: - `:all` → always true - `:own` → `record.id == user.id` - `:linked` → resource-specific: Member checks `record.user_id == user.id`; CustomFieldValue traverses `record.member.user_id == user.id` (member must be preloaded), with a `user_id` fallback for other resources. - `can_access_page?/2` — matches the path against the permission set's `pages` list using the same rules as the plug: `*` wildcard, exact match, or dynamic segment match (`:id`). All three return **false** for a nil user, a user without a role, or an invalid `permission_set_name` (graceful, fail-closed — no crash). The scope/page-matching logic mirrors `HasPermission` and `CheckPagePermission` exactly; resource names come from `Module.split() |> List.last()`. ### UI Usage Pattern LiveView templates gate elements with the helpers: page-level links use `can_access_page?(@current_user, path)` (e.g. the `/members` link and the admin dropdown), resource-level buttons use `can?(@current_user, :create, Resource)` (e.g. "New Member"), and per-record buttons use `can?(@current_user, action, record)` (e.g. Edit/Delete in a member row, or the edit button on a show page). The navbar has since been replaced by the sidebar (`lib/mv_web/components/layouts/sidebar.ex`). --- ## Special Cases ### 1. Own Credentials Access **Requirement:** Every user can ALWAYS read and update their own credentials (email, password), regardless of their role. **Implementation:** Policy in `User` resource uses a two-tier approach: - **READ**: Bypass with `expr()` for list queries (auto_filter) - **UPDATE**: HasPermission with `scope :own` (evaluates PermissionSets) ```elixir policies do # SPECIAL CASE: Users can always READ their own account # Bypass needed for list queries (expr() triggers auto_filter in Ash) bypass action_type(:read) do description "Users can always read their own account" authorize_if expr(id == ^actor(:id)) end # GENERAL: Check permissions from user's role # UPDATE uses scope :own from PermissionSets (all sets grant User.update :own) policy action_type([:read, :create, :update, :destroy]) do authorize_if Mv.Authorization.Checks.HasPermission end end ``` **Why this works:** - READ bypass handles list queries correctly (auto_filter) - UPDATE is handled by HasPermission with `scope :own` from PermissionSets - All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own` - Even a user with `read_only` (read-only for member data) can update their own credentials **Important:** UPDATE is NOT an immovable special case (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details. ### 1a. User Credentials: Why read_only Can Still Update **Question:** If `read_only` means "read-only", why can users with this permission set still update their own credentials? **Answer:** The `read_only` permission set refers to **member data**, NOT user credentials. All permission sets grant `User.update :own` to allow password changes and profile updates. **Implementation Details:** 1. **UPDATE is controlled by PermissionSets**, not a hardcoded bypass 2. **All 4 permission sets** (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant: ```elixir %{resource: "User", action: :update, scope: :own, granted: true} ``` 3. **HasPermission** evaluates `scope :own` for UPDATE operations (when a changeset with record is present) 4. **No special bypass** is needed for UPDATE - it works correctly via HasPermission **Why This Design?** - **Flexibility:** Permission sets can be modified to change UPDATE behavior - **Consistency:** All permissions are centralized in PermissionSets - **Clarity:** The name "read_only" refers to member data, not user credentials - **Maintainability:** Easy to see what each role can do in PermissionSets module **Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional — UPDATE is controlled by PermissionSets, not hardcoded. Every set's `get_permissions/...` therefore carries both `%{resource: "User", action: :read, scope: :own}` and `%{... action: :update, scope: :own}`; the "read_only" label applies to member data (no `Member :update`), not credentials. ### 2. Linked Member Email Editing **Requirement:** For a member linked to a user account (has a linked user), only administrators **or the linked user themselves** can change the email. This prevents breaking the Member↔User email synchronization while still letting a user update their own email. **Implementation:** The `Mv.Membership.Member.Validations.EmailChangePermission` module (registered as `validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]`) runs **after** the policy check (so a `normal_user` may update the member but is still blocked on the email field). It only acts when the email is changing: if the member has no linked user it allows the change; otherwise it allows the change when the actor is admin (`Mv.Authorization.Actor.admin?/1`, which also treats the system actor as admin) **or** owns the linked member (`actor.member_id == member.id`), and otherwise returns `{:error, "Only administrators or the linked user can change the email for members linked to users"}`. A missing actor is not allowed. ### 3. System Role Protection **Requirement:** The "Mitglied" role cannot be deleted (it's the default role for all users). **Implementation:** The `Role` resource has an `is_system_role` boolean (default false); a destroy validation returns `{:error, "Cannot delete system role. ..."}` when `role.is_system_role` is true. Seeds set `is_system_role: true` only on "Mitglied". The UI also hides the delete button: `can?(@current_user, :destroy, role) and not role.is_system_role`. ### 4. User Without Role (Edge Case) **Requirement:** Users without a role are denied all access (except logout). **Implementation:** Seeds assign "Mitglied" to all users where `role_id` is nil. At runtime every check handles a missing role gracefully — `HasPermission` returns `{:error, :no_role}` (and the UI helpers/plug return false) rather than crashing. **Result:** A user with no role sees an empty UI, cannot access pages, and is forbidden on all actions. ### 5. Invalid permission_set_name (Edge Case) **Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing. **Implementation:** Prevented up front by a `Role` attribute validation that rejects any value not in `PermissionSets.all_permission_sets/0` (`"Invalid permission set name. Must be one of: ..."`). At runtime, every lookup goes through `permission_set_name_to_atom/1`, which rescues the `ArgumentError` from `String.to_existing_atom/1` (see PermissionSets above), so an invalid name yields `{:error, :invalid_permission_set}`. **Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default). --- ## User-Member Linking ### Requirement Users and Members are separate entities that can be linked. Special rules: - Only admins can link/unlink users and members - A user cannot link themselves to an existing member - A user CAN create a new member and be directly linked to it (self-service) **Enforcement:** - **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit. - **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only. ### Approach: One Pair of Actions Plus an Admin-Only `:user` Argument Linking is **not** modelled as separate per-operation actions. The `Mv.Membership.Member` resource (`lib/membership/member.ex`) exposes the actions `create_member`, `update_member`, `set_vereinfacht_contact_id`, `search`, and `available_for_linking` (plus the default `:read`/`:destroy`). Linking and unlinking happen through the optional **`:user` argument** on `create_member` / `update_member`, not through dedicated `link_*`/`unlink_*` actions. (`user_id` is deliberately **not** in the accept list, so the foreign key cannot be set directly.) ### Implementation The user–member link is governed by two facts about `create_member` / `update_member`: - The `:user` argument drives the relationship via `manage_relationship(:user, ...)` with `on_lookup: :relate`, `on_no_match: :error`, `on_match: :error`, and **`on_missing: :ignore`**. Because of `on_missing: :ignore`, **omitting** `:user` leaves the link unchanged (no "unlink by omission"); unlink is explicit (`user: nil`/`user: %{}`), handled on update via the `UnrelateUserWhenArgumentNil` change. - Whether the `:user` argument may be used at all is gated by the policy check `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` (`forbid_if` before `authorize_if HasPermission` on `action_type([:create, :update])`). It forbids the action for a non-admin whenever the `:user` argument **key is present** (any value), so only admins may set or change the link. Non-admins can still create/update members as long as they do not pass `:user`. Self-service ("a user creates a member and is linked to it") is handled on the **User** side, not by a special Member action: the admin-only `update_user` action takes a `:member` argument for link/unlink (see Enforcement above), and the UI gates the linking controls on admin status. ### Why This Design? Keeping the link on a single `:user` argument (rather than a fan-out of `link_*`/`unlink_*` actions) means there is exactly one create and one update path to reason about, the admin-only rule lives in one reusable policy check (`ForbidMemberUserLinkUnlessAdmin`) instead of being duplicated per action, and `user_id` can never be mass-assigned because it is not accepted — only the argument-driven relationship management can change it. --- ## Future: Phase 2 - Field-Level Permissions **Status:** Not in MVP, planned for future enhancement **Goal:** Control which fields a user can read or write, beyond resource-level permissions. ### Strategy **Extend PermissionSets module with `:fields` key:** ```elixir def get_permissions(:read_only) do %{ resources: [...], pages: [...], fields: [ # Vorstand can read all member fields except sensitive payment info %{ resource: "Member", action: :read, fields: [:all], excluded_fields: [:payment_method, :bank_account] }, # Vorstand cannot write any member fields %{ resource: "Member", action: :update, fields: [] # Empty = no fields writable } ] } end ``` **Read filtering** via an Ash calculation that takes `allowed_fields` from PermissionSets and `Map.take/2`s each record to those fields. **Write protection** via an update validation that diffs `Map.keys(changeset.attributes)` against the allowed write fields and returns `{:error, "You do not have permission to modify: ..."}` for any forbidden field. **Benefits:** No database schema changes, still uses hardcoded PermissionSets, granular control over sensitive fields, clear error messages. **Estimated Effort:** 2-3 weeks --- ## Future: Phase 3 - Database-Backed Permissions **Status:** Not in MVP, planned for future when runtime configuration is needed **Goal:** Move permission definitions from code to database for runtime configuration. ### High-Level Design **New Tables:** ```sql CREATE TABLE permission_sets ( id UUID PRIMARY KEY, name VARCHAR(50) UNIQUE, description TEXT, is_system BOOLEAN ); CREATE TABLE permission_set_resources ( id UUID PRIMARY KEY, permission_set_id UUID REFERENCES permission_sets(id), resource_name VARCHAR(100), action VARCHAR(20), scope VARCHAR(20), granted BOOLEAN ); CREATE TABLE permission_set_pages ( id UUID PRIMARY KEY, permission_set_id UUID REFERENCES permission_sets(id), page_pattern VARCHAR(255) ); ``` **Migration Strategy:** 1. Create new tables 2. Seed from current `PermissionSets` module 3. Create new `HasResourcePermission` check that queries DB 4. Add ETS cache for performance 5. Replace `HasPermission` with `HasResourcePermission` in policies 6. Test thoroughly 7. Deploy 8. Eventually remove `PermissionSets` module **ETS Cache:** ```elixir defmodule Mv.Authorization.PermissionCache do def get_permissions(permission_set_id) do case :ets.lookup(:permission_cache, permission_set_id) do [{^permission_set_id, permissions}] -> permissions [] -> permissions = load_from_db(permission_set_id) :ets.insert(:permission_cache, {permission_set_id, permissions}) permissions end end def invalidate(permission_set_id) do :ets.delete(:permission_cache, permission_set_id) end end ``` **Benefits:** Runtime permission configuration, more flexible than hardcoded, can add new permission sets without code changes. **Trade-offs:** More complex (DB queries, cache, invalidation), slightly slower (mitigated by cache), more testing needed. **Estimated Effort:** 3-4 weeks **Decision Point:** Migrate to Phase 3 only if: - Need to add permission sets frequently - Need per-tenant permission customization - MVP hardcoded approach is limiting business See [Migration Strategy](#migration-strategy) for detailed migration plan. --- ## Migration Strategy ### Three-Phase Approach **Phase 1: MVP (2-3 weeks) - CURRENT** (shipped 2026-01-08, PR #346, closes #345) - Hardcoded PermissionSets module - `HasPermission` check reads from module - Role table with `permission_set_name` string - Zero DB queries for permission checks **What's NOT in MVP (deferred to Phase 3):** - `PermissionSetResource` database table - `PermissionSetPage` database table - ETS Permission Cache - Database-backed dynamic permissions / runtime permission editing **MVP DB migration & rollback.** Issue #1 adds a single migration: create the `roles` table (`name` unique, `permission_set_name`, `is_system_role`, timestamps; indexes on `name` and `permission_set_name`) and add nullable `users.role_id` FK (`ON DELETE RESTRICT`) with its index. The migration is additive only — no existing table is modified destructively. The 5 roles are created by `priv/repo/seeds_bootstrap.exs`, and the `assign_mitglied_role_to_existing_users` migration assigns "Mitglied" to users without a role. Rollback options, in order of escalation: 1. **DB rollback:** the `down` migration drops the `users.role_id` index, removes the `role_id` column, and drops the `roles` table — `mix ecto.rollback --step 1`. Existing tables are untouched. 2. **Code rollback:** revert the commit and redeploy the previous version. **Phase 2: Field-Level (2-3 weeks) - FUTURE** - Extend PermissionSets with `:fields` key - Ash Calculations for read filtering - Custom Validations for write protection - No database schema changes **Phase 3: Database-Backed (3-4 weeks) - FUTURE** - New tables: `permission_sets`, `permission_set_resources`, `permission_set_pages` - New `HasResourcePermission` check queries DB - ETS cache for performance - Runtime permission configuration ### When to Migrate? **Stay with MVP if:** - 4 permission sets are sufficient - Permission changes are rare (quarterly or less) - Code deployments for permission changes are acceptable - Performance is critical (< 1μs checks) **Migrate to Phase 2 if:** - Need field-level granularity - Different roles need access to different fields - Still OK with hardcoded permissions **Migrate to Phase 3 if:** - Need frequent permission changes - Need per-tenant customization - Want non-technical users to configure permissions - OK with slightly more complex system ### Migration from MVP to Phase 3 Sequence (~3-4 weeks): create the three permission tables + indexes; seed them from `PermissionSets.get_permissions/1`; add a `HasResourcePermission` check that queries the DB (same logic as `HasPermission`, different data source) backed by the ETS cache with invalidation on update; swap `HasPermission` → `HasResourcePermission` in all resources and point the UI helper + page plug at the DB/cache; integration + performance/load test; deploy behind the feature flag (run both systems in parallel to compare) then gradually to production; finally remove the old `HasPermission` check and `PermissionSets` module. --- ## Security Considerations ### Threat Model **Threats Addressed:** 1. **Unauthorized Data Access:** Policies prevent users from accessing data outside their permissions 2. **Privilege Escalation:** Role-based system prevents users from granting themselves higher privileges 3. **UI Tampering:** Backend policies enforce authorization even if UI is bypassed 4. **Session Hijacking:** Mitigation handled by existing authentication system (not in scope) **Threats NOT Addressed:** 1. **SQL Injection:** Ash Framework handles query building securely 2. **XSS:** Phoenix LiveView handles HTML escaping 3. **CSRF:** Phoenix CSRF tokens (existing) ### Defense in Depth **Three Layers of Authorization:** 1. **Page Access Layer (Plug):** - Blocks unauthorized page access - Runs before LiveView mounts - Fast fail for obvious violations 2. **UI Layer (Authorization Helpers):** - Hides buttons/links user can't use - Prevents confusing "forbidden" errors - Improves UX 3. **Resource Layer (Ash Policies):** - **Primary enforcement point** - Cannot be bypassed - Filters queries automatically **Even if attacker:** - Tampers with UI → Backend policies still enforce - Calls API directly → Policies apply - Modifies page JavaScript → Policies apply ### Authorization Best Practices **DO:** - ✅ Always preload `:role` relationship for actor - ✅ Log authorization failures for debugging - ✅ Use explicit policies (no implicit allow) - ✅ Test policies with all role types - ✅ Test special cases (nil role, invalid permission_set_name) **DON'T:** - ❌ Trust UI-level checks alone - ❌ Skip policy checks for "admin" - ❌ Use `bypass` or `skip_authorization` in production - ❌ Expose raw permission logic in API responses ### Audit Logging (Future) Not in MVP, but planned: persist authorization failures (user id, resource, action, outcome, reason, IP, timestamp) to an `AuditLog` resource — for tracking suspicious attempts, GDPR access logs, and production debugging. Currently failures are only `Logger`-logged. --- ## Appendix ### Glossary - **Permission Set:** Named collection of permissions (e.g., "admin", "read_only") - **Role:** Database entity linking users to a permission set; **system role** cannot be deleted (`is_system_role=true`) - **Scope:** Range of records a permission applies to (`:own`, `:linked`, `:all`) - **Actor:** Currently authenticated user in Ash context - **Special Case:** Authorization rule that takes precedence over general permissions ### Resource Name Mapping The `HasPermission` check extracts resource names via `Module.split() |> List.last()`: | Ash Module | Resource Name (String) | |------------|------------------------| | `Mv.Accounts.User` | "User" | | `Mv.Membership.Member` | "Member" | | `Mv.Membership.CustomFieldValue` | "CustomFieldValue" | | `Mv.Membership.CustomField` | "CustomField" | | `Mv.Authorization.Role` | "Role" | These strings must match exactly in `PermissionSets` module. ### Permission Set Summary | Permission Set | Typical Roles | Key Characteristics | |----------------|---------------|---------------------| | **own_data** | Mitglied | Can only access own data and linked member | | **read_only** | Vorstand, Buchhaltung | Read all data, no modifications | | **normal_user** | Kassenwart | Create/Read/Update members (no delete), full CRUD on properties, no admin | | **admin** | Admin | Unrestricted access, wildcard pages | ### Edge Case Reference | Edge Case | Behavior | Implementation | |-----------|----------|----------------| | User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully | | Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks | | System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` | | Linked member email | Admin or linked user may edit | `Member.Validations.EmailChangePermission` | | Own credentials | Always accessible | Special policy before general check | --- ## Authorization Bootstrap Patterns This section clarifies three different mechanisms for bypassing standard authorization, their purposes, and when to use each. ### Overview The codebase uses two authorization bypass mechanisms: 1. **system_actor** - Admin user for systemic operations 2. **authorize?: false** - Bootstrap bypass for circular dependencies **Both are necessary and serve different purposes.** **Note:** The NoActor bypass has been removed to prevent masking authorization bugs in tests. All tests now explicitly use `system_actor` for authorization. ### 1. System Actor **Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions. **Implementation:** ```elixir system_actor = Mv.Helpers.SystemActor.get_system_actor() # => %User{email: "system@mila.local", role: %{permission_set_name: "admin"}} ``` **Security:** - No password (hashed_password = nil) → cannot login - No OIDC ID (oidc_id = nil) → cannot authenticate - Cached in Agent for performance - Created automatically in test environment if missing **Use Cases:** - **Email synchronization** (User ↔ Member email sync) - **Email uniqueness validation** (cross-resource checks) - **Cycle generation** (mandatory side effect) - **OIDC account linking** (user not yet logged in) - **Cross-resource validations** (must work regardless of actor) **Example:** ```elixir def get_linked_member(%{member_id: id}) do system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) # Email sync must work regardless of user permissions Ash.get(Mv.Membership.Member, id, opts) end ``` **Why not `authorize?: false`?** - System actor is explicit (clear intent: "systemic operation") - Policies are evaluated (with admin rights) - Audit trail (actor.email = "system@mila.local") - Consistent authorization flow - Testable ### 2. authorize?: false **Purpose:** Skip policies for bootstrap scenarios with circular dependencies. **Use Cases:** **1. Seeds** - No admin exists yet to use as actor: ```elixir # priv/repo/seeds.exs Accounts.create_user!(%{email: admin_email}, authorize?: false # Bootstrap: no admin exists yet ) ``` **2. SystemActor Bootstrap** - Chicken-and-egg problem: ```elixir # lib/mv/helpers/system_actor.ex defp find_user_by_email(email) do # Need to find system actor, but loading requires system actor! Mv.Accounts.User |> Ash.Query.filter(email == ^email) |> Ash.read_one(authorize?: false) # Bootstrap only end ``` **3. Actor.ensure_loaded** - Circular dependency: ```elixir # lib/mv/authorization/actor.ex defp load_role(actor) do # Actor needs role for authorization, # but loading role requires authorization! Ash.load(actor, :role, authorize?: false) # Bootstrap only end ``` **4. assign_default_role** - User creation: ```elixir # User doesn't have actor during creation Mv.Authorization.Role |> Ash.Query.filter(name == "Mitglied") |> Ash.read_one(authorize?: false) # Bootstrap only ``` **Security:** - Very powerful - skips ALL policies - Use sparingly and document every usage - Only for bootstrap scenarios - All current usages are legitimate ### Comparison | Aspect | system_actor | authorize?: false | |--------|--------------|-------------------| | **Environment** | All | All | | **Actor** | Admin user | nil | | **Policies** | Evaluated | Skipped | | **Audit Trail** | Yes (system@mila.local) | No | | **Use Case** | Systemic operations, test fixtures | Bootstrap | | **Explicit?** | Function call | Query option | ### Decision Guide **Use system_actor when:** - ✅ Systemic operation must always succeed - ✅ Email synchronization - ✅ Cycle generation - ✅ Cross-resource validations - ✅ OIDC flows (user not logged in) **Use authorize?: false when:** - ✅ Bootstrap scenario (seeds) - ✅ Circular dependency (SystemActor bootstrap, Actor.ensure_loaded) - ⚠️ Document with comment explaining why **DON'T:** - ❌ Use `authorize?: false` for user-initiated actions - ❌ Use `authorize?: false` when `system_actor` would work - ❌ Skip actor in tests (always use system_actor) ### The Circular Dependency Problem **SystemActor Bootstrap:** ``` SystemActor.get_system_actor() ↓ calls find_user_by_email() ↓ needs to query User ↓ User policies require actor ↓ but we're loading the actor! Solution: authorize?: false for bootstrap query ``` **Actor.ensure_loaded:** ``` Authorization check (HasPermission) ↓ needs actor.role.permission_set_name ↓ but role is %Ash.NotLoaded{} ↓ load role with Ash.load(actor, :role) ↓ but loading requires authorization ↓ which needs actor.role! Solution: authorize?: false for role load ``` **Why this is safe:** - Actor is loading their OWN data (role relationship) - Actor already passed authentication boundary - Role contains no sensitive data (just permission_set reference) - Alternative (denormalize permission_set_name) adds complexity ### Examples **Good - system_actor for systemic operation:** ```elixir defp check_if_email_used(email) do system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) # Validation must work regardless of current actor Ash.read(User, opts) end ``` **Good - authorize?: false for bootstrap:** ```elixir # Seeds - no admin exists yet Accounts.create_user!(%{email: admin_email}, authorize?: false) ``` **Bad - authorize?: false for user action:** ```elixir # WRONG: Bypasses all policies for user-initiated action def delete_member(member) do Ash.destroy(member, authorize?: false) # ❌ Don't do this! end # CORRECT: Use actor def delete_member(member, actor) do Ash.destroy(member, actor: actor) # ✅ Policies enforced end ``` --- **Document Version:** 2.0 (Clean Rewrite) **Last Updated:** 2026-01-23 **Implementation Status:** ✅ Complete (2026-01-08) **Status:** Ready for Implementation **Changes from V1:** - Complete rewrite focused on MVP (hardcoded permissions) - Removed all database-backed permission details from MVP sections - Unified naming (HasPermission for MVP) - Added Role resource policies - Clarified resource-specific :linked scope - Moved Phase 2 and Phase 3 to clearly marked "Future" sections - Fixed Buchhaltung inconsistency (read_only everywhere) - Added comprehensive security section - Enhanced edge case documentation **Changes from V2.0:** - Added "Authorization Bootstrap Patterns" section explaining system_actor and authorize?: false - Removed NoActor bypass (all tests now use system_actor for explicit authorization) --- **End of Architecture Document**