# 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:** 2025-11-13 **Status:** Architecture Design - MVP Approach --- ## 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. **Advantages:** - Simplest database schema (single table) - Very flexible structure - No additional tables needed - Fast to implement **Disadvantages:** - Poor queryability (can't efficiently filter by specific permissions) - No referential integrity - Difficult to validate structure - Hard to audit permission changes - Can't leverage database indexes effectively **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. **Advantages:** - Fully queryable with SQL - Runtime configurable permissions - Strong referential integrity - Easy to audit changes - Can index for performance **Disadvantages:** - Complex database schema (4+ tables) - DB queries required for every permission check - Requires ETS cache for performance - Needs admin UI for permission management - Longer implementation time (4-5 weeks) - Overkill for fixed set of 4 permission 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. **Advantages:** - Complete control over authorization logic - Can implement any custom behavior - Not constrained by Ash Policy DSL **Disadvantages:** - Significantly more code to write and maintain - Loses benefits of Ash's declarative policies - Harder to test than built-in policy system - Mixes declarative and imperative approaches - Must reimplement filter generation for queries - Higher bug risk **Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits. --- ### Approach 4: Simple Role Enum Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy. **Advantages:** - Very simple to implement (< 1 week) - No extra tables needed - Fast performance - Easy to understand **Disadvantages:** - No separation between roles and permissions - Can't add new roles without code changes - No dynamic permission configuration - Not extensible to field-level permissions - Violates separation of concerns (role = job function, not permission set) - Difficult 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. **Advantages:** - Fast implementation (2-3 weeks vs 4-5 weeks) - Maximum performance (zero DB queries, < 1 microsecond) - Simple to test (pure functions) - Code-reviewable permissions (visible in Git) - No migration needed for existing data - Clearly defined 4 permission sets as required - Clear migration path to database-backed solution (Phase 3) - Maintains separation of roles and permission sets **Disadvantages:** - Permissions not editable at runtime (only role assignment possible) - New permissions require code deployment - Not suitable if permissions change frequently (> 1x/week) - Limited to the 4 predefined permission sets **Why Selected:** - MVP requirement is for 4 fixed permission sets (not custom ones) - No stated requirement for runtime permission editing - Performance is critical for authorization checks - Fast time-to-market (2-3 weeks) - Clear upgrade path when runtime configuration 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, Property, PropertyType, Role **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 admins can edit email of members linked to users 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", "Property", 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 where user_id == actor.id - 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: Separate Ash Actions Instead of complex field-level validation, we use action-based authorization. ### Actions on Member Resource **1. create_member_for_self** (All authenticated users) - Automatically sets user_id = actor.id - User cannot specify different user_id - UI: "Create My Profile" button **2. create_member** (Admin only) - Can set user_id to any user or leave unlinked - Full flexibility for admin - UI: Admin member management form **3. link_member_to_user** (Admin only) - Updates existing member to set user_id - Connects unlinked member to user account **4. unlink_member_from_user** (Admin only) - Sets user_id to nil - Disconnects member from user account **5. update** (Permission-based) - Normal updates (name, address, etc.) - user_id NOT in accept list (prevents manipulation) - Available to users with Member.update permission ### Why Separate Actions? **Explicit Semantics:** Each action has clear, single purpose **Server-Side Security:** user_id set by server, not client input **Better UX:** Different UI flows for different use cases **Simple Policies:** Authorization at action level, not field level **Easy Testing:** Each action independently testable --- ## 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):** Detailed implementation plan with TDD approach **[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards --- ## Summary The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing: - **Speed:** 2-3 weeks implementation vs 4-5 weeks - **Performance:** Zero database queries for authorization - **Clarity:** Permissions in Git, reviewable and testable - **Flexibility:** Clear migration path to database-backed system **User-Member linking** uses **separate Ash Actions** for clarity and security. **Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation. The approach balances pragmatism for MVP delivery with extensibility for future requirements.