# Roles and Permissions Architecture **Project:** Mila - Membership Management System **Feature:** Role-Based Access Control (RBAC) with Permission Sets **Version:** 1.0 **Last Updated:** 2025-11-10 **Status:** Architecture Design --- ## Table of Contents 1. [Overview](#overview) 2. [Requirements Analysis](#requirements-analysis) 3. [Evaluated Approaches](#evaluated-approaches) 4. [Selected Architecture](#selected-architecture) 5. [Database Schema](#database-schema) 6. [Permission System Design](#permission-system-design) 7. [Implementation Details](#implementation-details) 8. [Future Extensions](#future-extensions) 9. [Migration Strategy](#migration-strategy) 10. [Security Considerations](#security-considerations) --- ## Overview This document describes the architecture for implementing a flexible, scalable role-based access control (RBAC) system for the Mila membership management application. The system provides: - **Predefined Permission Sets** with configurable permissions - **Dynamic Roles** that reference permission sets - **Resource-level and Action-level** authorization - **Page-level** access control for LiveView routes - **Special handling** for credentials and linked user-member relationships - **Future extensibility** for field-level permissions ### Key Design Principles 1. **Separation of Concerns:** Permission Sets (what you can do) vs. Roles (job titles/functions) 2. **Flexibility:** Admins can configure permissions at runtime via database 3. **Performance:** Leverage Ash Framework policies with ETS caching 4. **Extensibility:** Architecture supports future field-level granularity 5. **Consistency:** Single unified permission model for all resources --- ## Requirements Analysis ### Core Requirements Based on the project requirements, the system must support: #### Permission Sets (4 Predefined) 1. **Own Data** - Users can only access their own data 2. **Read-Only** - Read access to all members, groups, and custom fields 3. **Normal User** - Read and write access to members and custom fields 4. **Admin** - Full access to all resources including user management #### Example Roles - **Mitglied** (Member) - Default role, own data access - **Vorstand** (Board) - Access to members, not users - **Kassenwart** (Treasurer) - Access to payment information - **Buchhaltung** (Accounting) - Read-only access - **Admin** - Full administrative access #### Authorization Granularity **Resource Level (Phase 1 - Now):** - Member: read, create, update, destroy - User: read, create, update, destroy - PropertyType: read, create, update, destroy - Property: read, create, update, destroy - Role: read, create, update, destroy - Payment (future): read, create, update, destroy **Page Level:** - Control access to LiveView pages - Pages are read-only access checks - Edit pages require both page access AND resource write permission **Field Level (Phase 2 - Later):** - Restrict read/write access to specific member fields - Restrict access to specific custom field types - Example: Treasurer sees payment_history, Board does not #### Special Cases 1. **User Credentials:** - Users can ALWAYS edit their own credentials (email, password) - Only Admins can edit OTHER users' credentials - Email field of members linked to users can only be edited by Admins 2. **Required Fields:** - When creating a member with required custom fields, user must be able to write those fields - Even if user normally doesn't have write permission for that field type - After creation, normal permissions apply 3. **Payment History (Future):** - Configurable per permission set - Members may or may not see their own payment history 4. **Linked User-Member Relationships:** - Member email sync follows special rules - User email is source of truth for linked members #### Constraints - Roles can be added, renamed, or removed by admins - Permission sets are predefined but permissions are configurable - Each user has exactly ONE role - Roles cannot overlap (no multiple role assignment per user) - "Mitglied" role is a system role and cannot be deleted - Permission sets are system-defined and cannot be deleted --- ## Evaluated Approaches We evaluated four different architectural approaches for implementing the authorization system: ### Approach 1: Ash Policies + RBAC with JSONB Permissions **Description:** Store permissions as JSONB in the Role resource, use custom Ash Policy checks to evaluate them. **Database Structure:** ``` roles (id, name, permissions_config: jsonb) users (role_id) ``` **Permissions stored as:** ```json { "resource_permissions": { "Member": {"read": true, "update": false} }, "page_permissions": { "/members": true } } ``` **Advantages:** - ✅ Simple database schema (fewer tables) - ✅ Flexible JSON structure - ✅ Fast schema changes (no migrations needed) - ✅ Easy to serialize/deserialize **Disadvantages:** - ❌ No referential integrity on permission keys - ❌ JSONB queries are less efficient than normalized tables - ❌ Difficult to query "which roles have access to X?" - ❌ Schema validation happens in application code - ❌ No indexing on individual permissions - ❌ Versioning of JSONB structure becomes complex **Verdict:** ❌ Not selected - JSONB makes querying and validation difficult --- ### Approach 2: Hybrid RBAC + ABAC with Permission Matrix **Description:** Separate tables for every permission type with full granularity from day one. **Database Structure:** ``` roles (id, name) permissions (id, resource, action, field, condition) role_permissions (role_id, permission_id, granted) user_roles (user_id, role_id) ``` **Advantages:** - ✅ Maximum flexibility - ✅ Highly granular from the start - ✅ Easy to add new permission types - ✅ Audit trail built-in **Disadvantages:** - ❌ Very complex database schema - ❌ High JOIN overhead on every authorization check - ❌ Over-engineered for current requirements - ❌ Difficult to cache effectively - ❌ Performance concerns with many permissions - ❌ Complex to seed and maintain **Verdict:** ❌ Not selected - Too complex for current needs, over-engineering --- ### Approach 3: Policy Graphs with Custom Authorizer **Description:** Use Ash Policies for action-level checks, custom Authorizer module for field-level filtering. **Database Structure:** ``` roles (id, name, permission_config) Custom Authorizer reads config and applies filters ``` **Advantages:** - ✅ Best performance (optimized for Ash) - ✅ Granular field-level control - ✅ Can leverage Ash query optimization **Disadvantages:** - ❌ Requires custom authorizer implementation (non-standard) - ❌ More code to maintain - ❌ Harder to test than declarative policies - ❌ Mixes declarative (Policies) and imperative (Authorizer) approaches **Verdict:** ❌ Not selected - Too much custom code, reduces maintainability --- ### Approach 4: Simple Role Enum (Quick Start) **Description:** Simple `:role` field on User with enum values, policies hardcoded in resources. **Database Structure:** ``` users (role: :admin | :vorstand | :kassenwart | :member) ``` **Advantages:** - ✅ Very simple to implement (1 week) - ✅ No extra tables needed - ✅ Fast performance - ✅ Easy to understand **Disadvantages:** - ❌ No dynamic permission configuration - ❌ Requires code deployment to change permissions - ❌ Can't add new roles without code changes - ❌ Not extensible to field-level permissions - ❌ Doesn't meet requirement for "configurable permissions" **Verdict:** ❌ Not selected - Doesn't meet core requirements --- ## Selected Architecture ### Approach 5: Permission Sets + Normalized Tables (Selected) **Description:** Hybrid approach that separates Permission Sets (what you can do) from Roles (who you are), with normalized database tables for queryability and Ash Policies for enforcement. **Key Innovation:** Introduce **Permission Sets** as an abstraction layer between Roles and actual Permissions. ``` Permission Set (4 predefined, defines capabilities) ↓ Role (many, references one Permission Set) ↓ User (each has one Role) ``` **Why This Approach?** 1. **Meets Requirements:** - ✅ Configurable permissions (stored in database) - ✅ Dynamic role creation - ✅ Extensible to field-level - ✅ Admin UI can modify at runtime 2. **Performance:** - ✅ Normalized tables allow efficient queries - ✅ Indexes on resource_name and action - ✅ ETS cache for permission lookups - ✅ Ash Policies translate to SQL filters 3. **Maintainability:** - ✅ Clear separation of concerns - ✅ Standard Ash patterns (not custom authorizer) - ✅ Testable with standard Ash policy tests - ✅ Easy to understand and debug 4. **Extensibility:** - ✅ `field_name` column reserved for Phase 2 - ✅ `scope` system handles "own" vs "all" vs "linked" - ✅ New resources just add permission rows - ✅ No code changes needed for new roles 5. **Flexibility:** - ✅ Permission Sets ensure consistency - ✅ Roles can be renamed without changing permissions - ✅ Multiple roles can share same permission set - ✅ Admin can configure at runtime **Trade-offs Accepted:** - More tables than JSONB approach (but better queryability) - More rows than enum approach (but runtime configurable) - Not as granular as full ABAC (but simpler to manage) --- ## Database Schema ### Entity Relationship Diagram ``` ┌─────────────────────┐ │ permission_sets │ │─────────────────────│ │ id (PK) │ │ name │◄───────┐ │ description │ │ │ is_system │ │ └─────────────────────┘ │ │ │ ┌─────────────────────────────┐│ │ permission_set_resources ││ │─────────────────────────────││ │ id (PK) ││ │ permission_set_id (FK) │┘ │ resource_name │ │ action │ │ scope │ │ field_name (nullable) │ │ granted │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ permission_set_pages │ │─────────────────────────────│ │ id (PK) │ │ permission_set_id (FK) │───┐ │ page_path │ │ └─────────────────────────────┘ │ │ │ ┌─────────────────────┐ │ │ roles │ │ │─────────────────────│ │ │ id (PK) │ │ │ name │ │ │ description │ │ │ permission_set_id │──────────┘ │ is_system_role │ └─────────────────────┘ ▲ │ │ ┌─────────────────────┐ │ users │ │─────────────────────│ │ id (PK) │ │ email │ │ hashed_password │ │ oidc_id │ │ member_id (FK) │ │ role_id (FK) │◄──── Default: "Mitglied" role └─────────────────────┘ ``` ### Table Definitions #### `permission_sets` Defines the 4 core permission sets. These are system-defined and cannot be deleted. ```sql CREATE TABLE permission_sets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL UNIQUE, description TEXT, is_system BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); -- Indexes CREATE INDEX idx_permission_sets_name ON permission_sets(name); ``` **Records:** - `own_data` - Users can only access their own data - `read_only` - Read access to all members and custom fields - `normal_user` - Read and write access to members and custom fields - `admin` - Full access to everything --- #### `permission_set_resources` Defines what actions each permission set can perform on which resources. ```sql CREATE TABLE permission_set_resources ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, resource_name VARCHAR(255) NOT NULL, -- "Member", "User", "PropertyType", etc. action VARCHAR(50) NOT NULL, -- "read", "create", "update", "destroy" scope VARCHAR(50), -- NULL/"all", "own", "linked" field_name VARCHAR(255), -- NULL = all fields, else specific field (Phase 2) granted BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); -- Indexes CREATE INDEX idx_psr_permission_set ON permission_set_resources(permission_set_id); CREATE INDEX idx_psr_resource_action ON permission_set_resources(resource_name, action); CREATE UNIQUE INDEX idx_psr_unique ON permission_set_resources( permission_set_id, resource_name, action, COALESCE(scope, 'all'), COALESCE(field_name, '') ); ``` **Scope Values:** - `NULL` or `"all"` - Permission applies to all entities of this resource - `"own"` - Permission applies only to user's own data (user.id == actor.id) - `"linked"` - Permission applies only to entities linked to user (e.g., member.user_id == actor.id) **Field Name (Phase 2):** - `NULL` - Permission applies to all fields (Phase 1 default) - `"field_name"` - Permission applies only to specific field (Phase 2) **Example Records:** ```sql -- Own Data Permission Set: User can read their own User record INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) VALUES (own_data_id, 'User', 'read', 'own', true); -- Read-Only Permission Set: Can read all Members INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) VALUES (read_only_id, 'Member', 'read', 'all', true); -- Normal User Permission Set: Can update all Members INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) VALUES (normal_user_id, 'Member', 'update', 'all', true); -- Admin Permission Set: Can destroy all Members INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) VALUES (admin_id, 'Member', 'destroy', 'all', true); ``` --- #### `permission_set_pages` Defines which LiveView pages each permission set can access. ```sql CREATE TABLE permission_set_pages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, page_path VARCHAR(255) NOT NULL, -- "/members", "/members/:id/edit", "/admin" created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); -- Indexes CREATE INDEX idx_psp_permission_set ON permission_set_pages(permission_set_id); CREATE INDEX idx_psp_page_path ON permission_set_pages(page_path); CREATE UNIQUE INDEX idx_psp_unique ON permission_set_pages(permission_set_id, page_path); ``` **Page Paths:** - Static paths: `/members`, `/users`, `/admin` - Dynamic paths: `/members/:id`, `/members/:id/edit` - Must match Phoenix Router routes exactly **Important:** Page permissions are READ-ONLY access checks. If a user shouldn't access an edit page, they don't get the page permission. The actual write operation is controlled by resource permissions. **Example Records:** ```sql -- Own Data: Only profile page INSERT INTO permission_set_pages (permission_set_id, page_path) VALUES (own_data_id, '/profile'); -- Read-Only: Member index and show pages INSERT INTO permission_set_pages (permission_set_id, page_path) VALUES (read_only_id, '/members'), (read_only_id, '/members/:id'); -- Normal User: Member pages including edit INSERT INTO permission_set_pages (permission_set_id, page_path) VALUES (normal_user_id, '/members'), (normal_user_id, '/members/new'), (normal_user_id, '/members/:id'), (normal_user_id, '/members/:id/edit'); -- Admin: All pages INSERT INTO permission_set_pages (permission_set_id, page_path) VALUES (admin_id, '/members'), (admin_id, '/members/new'), (admin_id, '/members/:id'), (admin_id, '/members/:id/edit'), (admin_id, '/users'), (admin_id, '/users/new'), (admin_id, '/users/:id'), (admin_id, '/users/:id/edit'), (admin_id, '/admin'); ``` --- #### `roles` Defines user roles that reference one permission set each. ```sql CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL UNIQUE, description TEXT, permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE RESTRICT, is_system_role BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); -- Indexes CREATE INDEX idx_roles_name ON roles(name); CREATE INDEX idx_roles_permission_set ON roles(permission_set_id); ``` **System Roles:** - `is_system_role = true` for "Mitglied" (default role) - System roles cannot be deleted - Can be renamed but must always exist **Example Records:** ```sql -- Mitglied (default role for all users) INSERT INTO roles (name, description, permission_set_id, is_system_role) VALUES ('Mitglied', 'Standard role for all members', own_data_id, true); -- Vorstand (board member with read access) INSERT INTO roles (name, description, permission_set_id, is_system_role) VALUES ('Vorstand', 'Board member with read access to all members', read_only_id, false); -- Kassenwart (treasurer with write access + payment info) INSERT INTO roles (name, description, permission_set_id, is_system_role) VALUES ('Kassenwart', 'Treasurer with access to payment information', normal_user_id, false); -- Buchhaltung (accounting with read access) INSERT INTO roles (name, description, permission_set_id, is_system_role) VALUES ('Buchhaltung', 'Accounting with read-only access', read_only_id, false); -- Admin (full access) INSERT INTO roles (name, description, permission_set_id, is_system_role) VALUES ('Admin', 'Full administrative access', admin_id, false); ``` --- #### `users` (Extended) Add `role_id` foreign key to existing users table. ```sql ALTER TABLE users ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; -- Set default to "Mitglied" role (via migration) UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL; ALTER TABLE users ALTER COLUMN role_id SET NOT NULL; -- Index CREATE INDEX idx_users_role ON users(role_id); ``` --- ## Permission System Design ### Permission Evaluation Flow ``` Request comes in (LiveView mount or Ash action) ↓ 1. Load Current User with Role preloaded ↓ 2. Check Page Permission (if LiveView) - Query: permission_set_pages WHERE page_path = current_path - If no match: DENY, redirect to "/" ↓ 3. Ash Policy Check (for resource actions) - Policy 1: Check "relates_to_actor" (own data) - Policy 2: Check custom permission via DB - Load permission_set_resources - Match: resource_name, action, scope - Evaluate scope: * "own" → Filter: id == actor.id * "linked" → Filter: user_id == actor.id * "all" → No filter - Policy 3: Default DENY ↓ 4. Special Validations (if applicable) - Member email change on linked member - Required fields on create ↓ 5. Execute Action or Render Page ``` ### Scope Evaluation The `scope` field determines which subset of records a permission applies to: #### Scope: `"own"` Used for resources where user has direct ownership. **Applicable to:** `User` **Filter Logic:** ```elixir {:filter, expr(id == ^actor.id)} ``` **Example:** - Own Data permission set has `User.read` with scope `"own"` - User can only read their own User record - Query becomes: `SELECT * FROM users WHERE id = $actor_id` --- #### Scope: `"linked"` Used for resources linked to user via intermediate relationship. **Applicable to:** `Member`, `Property`, `Payment` (future) **Filter Logic:** ```elixir # For Member {:filter, expr(user_id == ^actor.id)} # For Property (traverses relationship) {:filter, expr(member.user_id == ^actor.id)} # For Payment (future, traverses relationship) {:filter, expr(member.user_id == ^actor.id)} ``` **Example:** - Own Data permission set has `Member.read` with scope `"linked"` - User can only read Members linked to them (member.user_id == actor.id) - If user has no linked member: no results - Query becomes: `SELECT * FROM members WHERE user_id = $actor_id` --- #### Scope: `"all"` or `NULL` Used for full access to all records of a resource. **Applicable to:** All resources **Filter Logic:** ```elixir :authorized # No filter, all records allowed ``` **Example:** - Read-Only permission set has `Member.read` with scope `"all"` - User can read all Members - Query becomes: `SELECT * FROM members` (no WHERE clause for authorization) --- ### Policy Implementation in Ash Resources Each Ash resource defines policies that check permissions: ```elixir defmodule Mv.Membership.Member do use Ash.Resource, ... policies do # Policy 1: Users can always access their own linked member data # This bypasses permission checks for own data policy action_type([:read, :update]) do description "Users can always access their own member data if linked" authorize_if relates_to_actor_via(:user) end # Policy 2: Check database permissions # This is where permission_set_resources table is queried policy action_type([:read, :create, :update, :destroy]) do description "Check if actor's role has permission for this action" authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() end # Policy 3: Default deny # If no policy matched, forbid access policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end end ``` **Important:** Policy order matters! First matching policy wins. --- ### Custom Policy Check Implementation ```elixir defmodule Mv.Authorization.Checks.HasResourcePermission do @moduledoc """ Custom Ash Policy Check that evaluates database-stored permissions. Queries the permission_set_resources table based on actor's role and evaluates scope to return appropriate filter. """ use Ash.Policy.Check @impl true def type, do: :filter @impl true def match?(actor, context, _opts) do resource = context.resource action = context.action # Load actor's permission set (with caching) case get_permission_set(actor) do nil -> :forbidden permission_set -> # Query permission_set_resources table check_permission(permission_set.id, resource, action.name, actor, context) end end defp get_permission_set(nil), do: nil defp get_permission_set(actor) do # Try cache first (ETS) case Mv.Authorization.PermissionCache.get_permission_set(actor.id) do {:ok, permission_set} -> permission_set :miss -> # Load from database: user → role → permission_set load_and_cache_permission_set(actor) end end defp load_and_cache_permission_set(actor) do case Ash.load(actor, role: :permission_set) do {:ok, user_with_relations} -> permission_set = user_with_relations.role.permission_set Mv.Authorization.PermissionCache.put_permission_set(actor.id, permission_set) permission_set _ -> nil end end defp check_permission(permission_set_id, resource, action, actor, context) do resource_name = resource |> Module.split() |> List.last() # Query permission_set_resources query = Mv.Authorization.PermissionSetResource |> Ash.Query.filter( permission_set_id == ^permission_set_id and resource_name == ^resource_name and action == ^action and is_nil(field_name) # Phase 1: only resource-level ) case Ash.read_one(query) do {:ok, permission} -> evaluate_permission(permission, actor, context) _ -> :forbidden end end defp evaluate_permission(%{granted: false}, _actor, _context) do :forbidden end defp evaluate_permission(%{granted: true, scope: nil}, _actor, _context) do :authorized end defp evaluate_permission(%{granted: true, scope: "all"}, _actor, _context) do :authorized end defp evaluate_permission(%{granted: true, scope: "own"}, actor, _context) do # Return filter expression for Ash {:filter, expr(id == ^actor.id)} end defp evaluate_permission(%{granted: true, scope: "linked"}, actor, context) do resource = context.resource # Generate appropriate filter based on resource case resource do Mv.Membership.Member -> {:filter, expr(user_id == ^actor.id)} Mv.Membership.Property -> {:filter, expr(member.user_id == ^actor.id)} # Add more resources as needed _ -> :forbidden end end end ``` --- ## Implementation Details ### Phase 1: Resource and Page Level Permissions **Timeline:** Sprint 1-2 (2-3 weeks) **Deliverables:** 1. Database migrations for all permission tables 2. Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage 3. Custom policy checks 4. Permission cache (ETS) 5. Router plug for page permissions 6. Seeds for 4 permission sets and 5 roles 7. Admin UI for role management 8. Tests for all permission scenarios **Not Included in Phase 1:** - Field-level permissions (field_name is always NULL) - Payment history (resource doesn't exist yet) - Groups (not yet planned) --- ### Special Cases Implementation #### 1. User Credentials - Always Editable by Owner **Requirement:** Users can always edit their own email and password, regardless of permission set. **Implementation:** ```elixir defmodule Mv.Accounts.User do policies do # Policy 1: Users can ALWAYS read and update their own credentials # This comes BEFORE permission checks policy action_type([:read, :update]) do description "Users can always access and update their own credentials" authorize_if expr(id == ^actor(:id)) end # Policy 2: Check permission set (for admins accessing other users) policy action_type([:read, :create, :update, :destroy]) do authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() end # Policy 3: Default deny policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end end ``` **Result:** - Mitglied role: Can edit own User record (own email/password) - Admin role: Can edit ANY User record (including others' credentials) - Other roles: Cannot access User resource unless specifically granted --- #### 2. Member Email for Linked Members - Admin Only **Requirement:** If a member is linked to a user, only admins can edit the member's email field. **Implementation:** ```elixir defmodule Mv.Membership.Member do validations do validate fn changeset, context -> # Only check if email is being changed if Ash.Changeset.changing_attribute?(changeset, :email) do member = changeset.data actor = context.actor # Load member's user relationship case Ash.load(member, :user) do {:ok, %{user: %{id: _user_id}}} -> # Member IS linked to a user # Check if actor has permission to edit ALL users if has_permission_for_all_users?(actor) do :ok else {:error, field: :email, message: "Only admins can edit email of members linked to users"} end {:ok, %{user: nil}} -> # Member is NOT linked # Normal Member.update permission applies :ok {:error, _} -> :ok end else :ok end end end defp has_permission_for_all_users?(actor) do # Check if actor's permission set has User.update with scope="all" permission_set = get_permission_set(actor) Mv.Authorization.PermissionSetResource |> Ash.Query.filter( permission_set_id == ^permission_set.id and resource_name == "User" and action == "update" and scope == "all" and granted == true ) |> Ash.exists?() end end ``` **Result:** - Admin: Can edit email of any member (including linked ones) - Normal User/Read-Only: Can edit email of unlinked members only - Attempting to edit email of linked member without permission: Validation error --- #### 3. Required Custom Fields on Member Creation **Requirement:** When creating a member with required custom fields, user must be able to set those fields even if they normally don't have permission. **Implementation:** For Phase 1, this is not an issue because: - PropertyType.required flag exists but isn't enforced yet - No field-level permissions exist yet - If user has Member.create permission, they can set Properties For Phase 2 (when field-level permissions exist): ```elixir defmodule Mv.Membership.Property do actions do create :create_property do # Special handling for required properties during member creation change Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate end end end defmodule Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate do use Ash.Resource.Change def change(changeset, _opts, context) do # Check if this is part of a member creation if creating_member?(context) do property_type_id = Ash.Changeset.get_attribute(changeset, :property_type_id) # Load PropertyType case Ash.get(Mv.Membership.PropertyType, property_type_id) do {:ok, %{required: true}} -> # This is a required field, allow creation even without normal permission # Set special context flag Ash.Changeset.set_context(changeset, :bypass_property_permission, true) _ -> changeset end else changeset end end end ``` --- ### Page Permission Implementation **Router Configuration:** ```elixir defmodule MvWeb.Router do use MvWeb, :router import MvWeb.Authorization # Pipeline with permission check pipeline :require_page_permission do plug :put_secure_browser_headers plug :fetch_current_user plug MvWeb.Plugs.CheckPagePermission end scope "/", MvWeb do pipe_through [:browser, :require_authenticated_user, :require_page_permission] # These routes automatically check page permissions live "/members", MemberLive.Index, :index live "/members/new", MemberLive.Index, :new live "/members/:id", MemberLive.Show, :show live "/members/:id/edit", MemberLive.Index, :edit live "/users", UserLive.Index, :index live "/users/new", UserLive.Index, :new live "/users/:id", UserLive.Show, :show live "/users/:id/edit", UserLive.Index, :edit live "/property-types", PropertyTypeLive.Index, :index live "/property-types/new", PropertyTypeLive.Index, :new live "/property-types/:id/edit", PropertyTypeLive.Index, :edit live "/admin", AdminLive.Dashboard, :index end end ``` **Page Permission Plug:** ```elixir defmodule MvWeb.Plugs.CheckPagePermission do @moduledoc """ Plug that checks if current user has permission to access the current page. Queries permission_set_pages table based on user's role → permission_set. """ import Plug.Conn import Phoenix.Controller def init(opts), do: opts def call(conn, _opts) do user = conn.assigns[:current_user] page_path = get_page_path(conn) if has_page_permission?(user, page_path) do conn else conn |> put_flash(:error, "You don't have permission to access this page.") |> redirect(to: "/") |> halt() end end defp get_page_path(conn) do # Extract route template from conn # "/members/:id/edit" from actual "/members/123/edit" case conn.private[:phoenix_route] do {_, _, _, route_template, _} -> route_template _ -> conn.request_path end end defp has_page_permission?(nil, _page_path), do: false defp has_page_permission?(user, page_path) do # Try cache first case Mv.Authorization.PermissionCache.get_page_permission(user.id, page_path) do {:ok, has_permission} -> has_permission :miss -> # Load from database and cache has_permission = check_page_permission_db(user, page_path) Mv.Authorization.PermissionCache.put_page_permission(user.id, page_path, has_permission) has_permission end end defp check_page_permission_db(user, page_path) do # Load user → role → permission_set case Ash.load(user, role: :permission_set) do {:ok, user_with_relations} -> permission_set_id = user_with_relations.role.permission_set.id # Check if permission_set_pages has this page Mv.Authorization.PermissionSetPage |> Ash.Query.filter( permission_set_id == ^permission_set_id and page_path == ^page_path ) |> Ash.exists?() _ -> false end end end ``` **Important:** Both page permission AND resource permission must be true for edit operations: - User needs `/members/:id/edit` page permission to see the page - User needs `Member.update` permission to actually save changes - If user has page permission but not resource permission: page loads but save fails --- ### Permission Cache Implementation **ETS Cache for Performance:** ```elixir defmodule Mv.Authorization.PermissionCache do @moduledoc """ ETS-based cache for user permissions to avoid database lookups on every request. Cache stores: - User's permission_set (user_id → permission_set) - Page permissions (user_id + page_path → boolean) - Resource permissions (user_id + resource + action → permission) Cache is invalidated when: - User's role changes - Role's permission_set changes - Permission set's permissions change """ use GenServer @table_name :permission_cache # Client API def start_link(_) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def get_permission_set(user_id) do case :ets.lookup(@table_name, {:permission_set, user_id}) do [{_, permission_set}] -> {:ok, permission_set} [] -> :miss end end def put_permission_set(user_id, permission_set) do :ets.insert(@table_name, {{:permission_set, user_id}, permission_set}) :ok end def get_page_permission(user_id, page_path) do case :ets.lookup(@table_name, {:page, user_id, page_path}) do [{_, has_permission}] -> {:ok, has_permission} [] -> :miss end end def put_page_permission(user_id, page_path, has_permission) do :ets.insert(@table_name, {{:page, user_id, page_path}, has_permission}) :ok end def invalidate_user(user_id) do # Delete all entries for this user :ets.match_delete(@table_name, {{:permission_set, user_id}, :_}) :ets.match_delete(@table_name, {{:page, user_id, :_}, :_}) :ok end def invalidate_all do :ets.delete_all_objects(@table_name) :ok end # Server Callbacks def init(_) do table = :ets.new(@table_name, [ :set, :public, :named_table, read_concurrency: true, write_concurrency: true ]) {:ok, %{table: table}} end end ``` **Cache Invalidation Strategy:** ```elixir defmodule Mv.Authorization.Role do # After updating role's permission_set changes do change after_action(fn changeset, role, _context -> # Invalidate cache for all users with this role invalidate_users_with_role(role.id) {:ok, role} end), on: [:update] end defp invalidate_users_with_role(role_id) do # Find all users with this role users = Mv.Accounts.User |> Ash.Query.filter(role_id == ^role_id) |> Ash.read!() # Invalidate each user's cache Enum.each(users, fn user -> Mv.Authorization.PermissionCache.invalidate_user(user.id) end) end end defmodule Mv.Authorization.PermissionSetResource do # After updating permissions changes do change after_action(fn changeset, permission, _context -> # Invalidate all users with this permission set invalidate_permission_set(permission.permission_set_id) {:ok, permission} end), on: [:create, :update, :destroy] end defp invalidate_permission_set(permission_set_id) do # Find all roles with this permission set roles = Mv.Authorization.Role |> Ash.Query.filter(permission_set_id == ^permission_set_id) |> Ash.read!() # Invalidate all users with these roles Enum.each(roles, fn role -> invalidate_users_with_role(role.id) end) end end ``` --- ### UI-Level Authorization **Requirement:** The user interface should only display links, buttons, and fields that the user has permission to access. This improves UX and prevents confusion. **Key Principles:** 1. **Navigation Links:** Hide links to pages the user cannot access 2. **Action Buttons:** Hide "Edit", "Delete", "New" buttons when user lacks permissions 3. **Form Fields:** In Phase 2, hide fields the user cannot read/write 4. **Proactive UI:** Never show a clickable element that would result in "Forbidden" --- #### Implementation Approach **Helper Module:** `MvWeb.Authorization` ```elixir defmodule MvWeb.Authorization do @moduledoc """ UI-level authorization helpers for LiveView. These helpers check permissions and determine what UI elements to show. They work in conjunction with Ash Policies (which are the actual enforcement). """ alias Mv.Authorization.PermissionCache alias Mv.Authorization @doc """ Checks if actor can perform action on resource. ## Examples # In LiveView template <%= if can?(@current_user, :update, Mv.Membership.Member) do %> <% end %> # In LiveView module if can?(socket.assigns.current_user, :create, Mv.Membership.PropertyType) do # Show "New Custom Field" button end """ def can?(nil, _action, _resource), do: false def can?(user, action, resource) when is_atom(action) and is_atom(resource) do resource_name = resource_name(resource) # Check cache first case get_permission_from_cache(user.id, resource_name, action) do {:ok, result} -> result :miss -> check_permission_from_db(user, resource_name, action) end end @doc """ Checks if actor can access a specific page path. ## Examples # In navigation component <%= if can_access_page?(@current_user, "/members") do %> <.link navigate="/members">Members <% end %> """ def can_access_page?(nil, _page_path), do: false def can_access_page?(user, page_path) do # Check cache first case PermissionCache.get_page_permission(user.id, page_path) do {:ok, result} -> result :miss -> check_page_permission_from_db(user, page_path) end end @doc """ Checks if actor can perform action on a specific record. This respects scope restrictions (own, linked, all). ## Examples # Show edit button only if user can edit THIS member <%= if can?(@current_user, :update, member) do %> <% end %> """ def can?(nil, _action, _record), do: false def can?(user, action, %resource{} = record) when is_atom(action) do resource_name = resource_name(resource) # First check if user has any permission for this action case get_permission_from_cache(user.id, resource_name, action) do {:ok, false} -> false {:ok, true} -> # User has permission, now check scope check_scope_for_record(user, action, resource, record) :miss -> check_permission_and_scope_from_db(user, action, resource, record) end end # Private helpers defp resource_name(Mv.Accounts.User), do: "User" defp resource_name(Mv.Membership.Member), do: "Member" defp resource_name(Mv.Membership.Property), do: "Property" defp resource_name(Mv.Membership.PropertyType), do: "PropertyType" defp get_permission_from_cache(user_id, resource_name, action) do # Try to get from cache # Returns {:ok, true}, {:ok, false}, or :miss case PermissionCache.get_permission_set(user_id) do {:ok, permission_set} -> # Check if this permission set has the permission has_permission = permission_set.resources |> Enum.any?(fn p -> p.resource_name == resource_name and p.action == to_string(action) and p.granted == true end) {:ok, has_permission} :miss -> :miss end end defp check_permission_from_db(user, resource_name, action) do # Load user's role and permission set user = Ash.load!(user, role: [permission_set: :resources]) has_permission = user.role.permission_set.resources |> Enum.any?(fn p -> p.resource_name == resource_name and p.action == to_string(action) and p.granted == true end) # Cache the entire permission set PermissionCache.put_permission_set(user.id, user.role.permission_set) has_permission end defp check_page_permission_from_db(user, page_path) do user = Ash.load!(user, role: [permission_set: :pages]) has_access = user.role.permission_set.pages |> Enum.any?(fn p -> p.page_path == page_path end) # Cache this specific page permission PermissionCache.put_page_permission(user.id, page_path, has_access) has_access end defp check_scope_for_record(user, action, resource, record) do # Load the permission to check scope user = Ash.load!(user, role: [permission_set: :resources]) resource_name = resource_name(resource) permission = user.role.permission_set.resources |> Enum.find(fn p -> p.resource_name == resource_name and p.action == to_string(action) and p.granted == true end) case permission do nil -> false %{scope: "all"} -> true %{scope: "own"} when resource == Mv.Accounts.User -> # Check if record.id == user.id record.id == user.id %{scope: "linked"} when resource == Mv.Membership.Member -> # Check if record.user_id == user.id record_with_user = Ash.load!(record, :user) case record_with_user.user do nil -> false %{id: user_id} -> user_id == user.id end %{scope: "linked"} when resource == Mv.Membership.Property -> # Check if record.member.user_id == user.id record_with_member = Ash.load!(record, member: :user) case record_with_member.member do nil -> false %{user: nil} -> false %{user: %{id: user_id}} -> user_id == user.id end _ -> false end end defp check_permission_and_scope_from_db(user, action, resource, record) do case check_permission_from_db(user, resource_name(resource), action) do false -> false true -> check_scope_for_record(user, action, resource, record) end end end ``` --- #### Usage in LiveView Templates **Navigation Component:** ```heex ``` **Index Page with Action Buttons:** ```heex