# Roles and Permissions - Architecture Overview **Project:** Mila - Membership Management System **Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets **Version:** 2.0 **Last Updated:** 2026-01-13 **Status:** ✅ Implemented (2026-01-08, PR #346, closes #345) --- ## Purpose of This Document This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts. **For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) --- ## Table of Contents 1. [Overview](#overview) 2. [Requirements Summary](#requirements-summary) 3. [Evaluated Approaches](#evaluated-approaches) 4. [Selected Architecture](#selected-architecture) 5. [Permission System Design](#permission-system-design) 6. [User-Member Linking Strategy](#user-member-linking-strategy) 7. [Field-Level Permissions Strategy](#field-level-permissions-strategy) 8. [Migration Strategy](#migration-strategy) 9. [Related Documents](#related-documents) --- ## Overview The Mila membership management system requires a flexible authorization system that controls: - **Who** can access **what** resources - **Which** pages users can view - **How** users interact with their own vs. others' data ### Key Design Principles 1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery 2. **Performance:** No database queries for permission checks in MVP 3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed 4. **Security:** Explicit action-based authorization with no ambiguity 5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions ### Core Concepts **Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin") **Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only") **User:** Each user has exactly one Role, inheriting that Role's Permission Set **Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything) --- ## Evaluated Approaches During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility. ### Approach 1: JSONB in Roles Table Store all permissions as a single JSONB column directly in the roles table. Simplest schema (single table), flexible, fast to implement — but poor queryability (can't filter by specific permissions), no referential integrity, hard to validate/audit, can't use indexes. **Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic. --- ### Approach 2: Normalized Database Tables Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. Fully queryable, runtime-configurable, strong referential integrity, auditable, indexable — but complex schema (4+ tables), a DB query per check, needs ETS cache + admin UI, 4-5 weeks, overkill for 4 fixed sets. **Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP. --- ### Approach 3: Custom Authorizer Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Full control over logic — but significantly more code, loses Ash's declarative policies (must reimplement query filter generation), harder to test, mixes declarative/imperative, higher bug risk. **Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits. --- ### Approach 4: Simple Role Enum Add a `:role` enum field directly on User with hardcoded checks in each policy. Very simple (< 1 week), no extra tables, fast — but no separation of role (job function) from permission set, can't add roles without code changes, no dynamic config, not extensible to field-level, hard to maintain as requirements grow. **Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation. --- ### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP) Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (2-3 weeks vs 4-5), maximum performance (zero DB queries, < 1μs), pure-function testing, Git-reviewable permissions, no data migration, keeps role/permission-set separation, clear Phase 3 upgrade path. Trade-offs: permissions not editable at runtime (only role assignment), new permissions need a code deploy, unsuitable if permissions change > 1x/week, limited to the 4 predefined sets. **Why Selected:** MVP requires 4 fixed sets (not custom ones), no stated need for runtime permission editing, performance is critical, fast time-to-market, and a clear upgrade path exists when runtime config becomes necessary. **Migration Path:** When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module. --- ## Requirements Summary ### Four Predefined Permission Sets 1. **own_data** - Access only to own user account and linked member profile 2. **read_only** - Read access to all members and custom fields 3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety) 4. **admin** - Unrestricted access to all resources including user management ### Example Roles - **Mitglied (Member)** - Uses "own_data" permission set, default role - **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 ### Authorization Levels **Resource Level (MVP):** - Controls create, read, update, destroy actions on resources - Resources: Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle, JoinRequest **Page Level (MVP):** - Controls access to LiveView pages - Example: "/members/new" requires Member.create permission **Field Level (Phase 2 - Future):** - Controls read/write access to specific fields - Example: Only Treasurer can see payment_history field ### Special Cases 1. **Own Credentials:** Users can always edit their own email and password 2. **Linked Member Email:** Only administrators or the linked user themselves can change the email of a member linked to a user 3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation) --- ## Selected Architecture ### Conceptual Model ``` Elixir Module: PermissionSets ↓ (defines) Permission Set (:own_data, :read_only, :normal_user, :admin) ↓ (referenced by) Role (stored in DB: "Vorstand" → "read_only") ↓ (assigned to) User (each user has one role_id) ``` ### Database Schema (MVP) **Single Table: roles** Contains: - id (UUID) - name (e.g., "Vorstand") - description - permission_set_name (String: "own_data", "read_only", "normal_user", "admin") - is_system_role (boolean, protects critical roles) **No Permission Tables:** Permission Sets are hardcoded in Elixir module. ### Why This Approach? **Fast Implementation:** 2-3 weeks instead of 4-5 weeks **Maximum Performance:** - Zero database queries for permission checks - Pure function calls (< 1 microsecond) - No caching needed **Code Review:** - Permissions visible in Git diffs - Easy to review changes - No accidental runtime modifications **Clear Upgrade Path:** - Phase 1 (MVP): Hardcoded - Phase 2: Add field-level permissions - Phase 3: Migrate to database-backed with admin UI **Meets Requirements:** - Four predefined permission sets ✓ - Dynamic role creation ✓ (Roles in DB) - Role-to-user assignment ✓ - No requirement for runtime permission changes stated --- ## Permission System Design ### Permission Structure Each Permission Set contains: **Resources:** List of resource permissions - resource: "Member", "User", "CustomFieldValue", etc. - action: :read, :create, :update, :destroy - scope: :own, :linked, :all - granted: true/false **Pages:** List of accessible page paths - Examples: "/", "/members", "/members/:id/edit" - "*" for admin (all pages) ### Scope Definitions **:own** - Only records where id == actor.id - Example: User can read their own User record **:linked** - Only records linked to actor via relationships - Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship) - CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship) - Example: User can read Member linked to their account **:all** - All records without restriction - Example: Admin can read all Members ### How Authorization Works 1. User attempts action on resource (e.g., read Member) 2. System loads user's role from database 3. Role contains permission_set_name string 4. PermissionSets module returns permissions for that set 5. Custom Policy Check evaluates permissions against action 6. Access granted or denied based on scope ### Custom Policy Check A reusable Ash Policy Check that: - Reads user's permission_set_name from their role - Calls PermissionSets.get_permissions/1 - Matches resource + action against permissions list - Applies scope filters (own/linked/all) - Returns authorized, forbidden, or filtered query --- ## User-Member Linking Strategy ### Problem Statement Users need to create member profiles for themselves (self-service), but only admins should be able to: - Link existing members to users - Unlink members from users - Create members pre-linked to arbitrary users ### Selected Approach: Admin-Only `:user` Argument Linking is **not** modelled as separate per-operation actions. The Member resource has a single `create_member` and a single `update_member` action; linking and unlinking happen through an optional **`:user` argument** on those actions. `user_id` is deliberately not accepted, so the foreign key cannot be set directly. ### How Linking Works on the Member Resource **`create_member` / `update_member`** (the only Member write actions) - The optional `:user` argument drives the relationship via `manage_relationship`. - On update, `on_missing: :ignore` means omitting `:user` leaves the link unchanged (no "unlink by omission"); unlink is explicit (`user: nil`). - The policy check `ForbidMemberUserLinkUnlessAdmin` forbids the action for non-admins whenever the `:user` argument 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 linked to themselves") is handled on the **User** side: the admin-only `update_user` action takes a `:member` argument for link/unlink, and the UI exposes the linking controls only to admins. ### Why This Design? **Single write path:** one create and one update action to reason about, instead of a fan-out of `link_*`/`unlink_*` actions. **Centralized rule:** the admin-only constraint lives in one reusable policy check (`ForbidMemberUserLinkUnlessAdmin`). **Server-Side Security:** `user_id` is never accepted directly, so it cannot be mass-assigned — only argument-driven relationship management can change it. **Better UX:** distinct UI flows for self-service vs. admin linking. --- ## Field-Level Permissions Strategy ### Status: Phase 2 (Future Implementation) Field-level permissions are NOT implemented in MVP but have a clear strategy defined. ### Problem Statement Some scenarios require field-level control: - **Read restrictions:** Hide payment_history from certain roles - **Write restrictions:** Only treasurer can edit payment fields - **Complexity:** Ash Policies work at resource level, not field level ### Selected Strategy **For Read Restrictions:** Use Ash Calculations or Custom Preparations - Calculations: Dynamically compute field based on permissions - Preparations: Filter select to only allowed fields - Field returns nil or "[Hidden]" if unauthorized **For Write Restrictions:** Use Custom Validations - Validate changeset against field permissions - Similar to existing linked-member email validation - Return error if field modification not allowed ### Why This Strategy? **Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer **Performance:** Calculations are lazy, Preparations run once per query **Maintainable:** Clear validation logic, standard Ash patterns **Extensible:** Easy to add new field restrictions ### Implementation Timeline **Phase 1 (MVP):** No field-level permissions **Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations **Phase 3:** If migrating to database, add permission_set_fields table --- ## Migration Strategy ### Phase 1: MVP with Hardcoded Permissions (2-3 weeks) **What's Included:** - Roles table in database - PermissionSets Elixir module with 4 predefined sets - Custom Policy Check reading from module - UI Authorization Helpers for LiveView - Admin UI for role management (create, assign, delete roles) **Limitations:** - Permissions not editable at runtime - New permissions require code deployment - Only 4 permission sets available **Benefits:** - Fast implementation - Maximum performance - Simple testing and review ### Phase 2: Field-Level Permissions (Future, 2-3 weeks) **When Needed:** Business requires field-level restrictions **Implementation:** - Extend PermissionSets module with :fields key - Add Ash Calculations for read restrictions - Add custom validations for write restrictions - Update UI Helpers **Migration:** No database changes, pure code additions ### Phase 3: Database-Backed Permissions (Future, 3-4 weeks) **When Needed:** Runtime permission configuration required **Implementation:** - Create permission tables in database - Seed script to migrate hardcoded permissions - Update PermissionSets module to query database - Add ETS cache for performance - Build admin UI for permission management **Migration:** Seamless, no changes to existing Policies or UI code ### Decision Matrix: When to Migrate? | Scenario | Recommended Phase | |----------|-------------------| | MVP with 4 fixed permission sets | Phase 1 | | Need field-level restrictions | Phase 2 | | Permission changes < 1x/month | Stay Phase 1 | | Need runtime permission config | Phase 3 | | Custom permission sets needed | Phase 3 | | Permission changes > 1x/week | Phase 3 | --- ## Related Documents **This Document (Overview):** High-level concepts, no code examples **[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples **[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Historical record of how the MVP was built (PR #346/#345) **[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards