# 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 <.table rows={@members}> <:col :let={member} label="Name"> <%= member.first_name %> <%= member.last_name %> <:col :let={member} label="Email"> <%= member.email %> <:col :let={member} label="Actions"> <.link navigate={~p"/members/#{member}"} class="btn-secondary"> Show <%= if can?(@current_user, :update, member) do %> <.link patch={~p"/members/#{member}/edit"} class="btn-secondary"> Edit <% end %> <%= if can?(@current_user, :destroy, member) do %> <.button phx-click="delete" phx-value-id={member.id} class="btn-danger"> Delete <% end %> ``` **Show Page:** ```heex
First Name
<%= @member.first_name %>
Last Name
<%= @member.last_name %>
Email
<%= @member.email %>
<%= if can_read_field?(@current_user, @member, :birth_date) do %>
Birth Date
<%= @member.birth_date %>
<% end %>
``` --- #### Usage in LiveView Modules **Mount Hook:** ```elixir defmodule MvWeb.MemberLive.Index do use MvWeb, :live_view import MvWeb.Authorization def mount(_params, _session, socket) do current_user = socket.assigns.current_user # Check if user can even access this page # (This is redundant with router plug, but provides better UX) unless can_access_page?(current_user, "/members") do {:ok, socket |> put_flash(:error, "You don't have permission to access this page") |> redirect(to: ~p"/")} else members = list_members(current_user) {:ok, socket |> assign(:members, members) |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))} end end defp list_members(current_user) do # Ash automatically filters based on policies Mv.Membership.Member |> Ash.read!(actor: current_user) end def handle_event("delete", %{"id" => id}, socket) do current_user = socket.assigns.current_user member = Ash.get!(Mv.Membership.Member, id, actor: current_user) # Double-check permission (though Ash will also enforce) if can?(current_user, :destroy, member) do case Ash.destroy(member, actor: current_user) do {:ok, _} -> {:noreply, socket |> put_flash(:info, "Member deleted successfully") |> push_navigate(to: ~p"/members")} {:error, _} -> {:noreply, put_flash(socket, :error, "Failed to delete member")} end else {:noreply, put_flash(socket, :error, "Permission denied")} end end end ``` --- #### Performance Considerations **Caching:** - Permission checks use ETS cache (from PermissionCache) - First call loads from DB and caches - Subsequent calls use cache - Cache invalidated on role/permission changes **Batch Checking:** For tables with many rows, we can optimize by checking once per resource type: ```elixir def mount(_params, _session, socket) do current_user = socket.assigns.current_user members = list_members(current_user) # Check permissions once for the resource type can_update_any = can?(current_user, :update, Mv.Membership.Member) can_destroy_any = can?(current_user, :destroy, Mv.Membership.Member) # Then check scope for each member (if needed) members_with_permissions = Enum.map(members, fn member -> %{ member: member, can_update: can_update_any && can_update_this?(current_user, member), can_destroy: can_destroy_any && can_destroy_this?(current_user, member) } end) {:ok, socket |> assign(:members_with_permissions, members_with_permissions) |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))} end ``` --- #### Testing UI Authorization **Test Strategy:** ```elixir # test/mv_web/member_live/index_test.exs defmodule MvWeb.MemberLive.IndexTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest describe "UI authorization for Mitglied role" do test "does not show 'New Member' button", %{conn: conn} do user = create_user_with_role("Mitglied") member = create_member_linked_to_user(user) conn = log_in_user(conn, user) {:ok, view, html} = live(conn, ~p"/members") refute html =~ "New Member" refute has_element?(view, "a", "New Member") end test "shows only 'Show' button for own member", %{conn: conn} do user = create_user_with_role("Mitglied") member = create_member_linked_to_user(user) conn = log_in_user(conn, user) {:ok, view, html} = live(conn, ~p"/members") # Show button should exist assert has_element?(view, "a[href='/members/#{member.id}']", "Show") # Edit and Delete buttons should NOT exist refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") refute has_element?(view, "button[phx-click='delete']", "Delete") end test "does not show 'Users' link in navigation", %{conn: conn} do user = create_user_with_role("Mitglied") conn = log_in_user(conn, user) {:ok, view, html} = live(conn, ~p"/") refute html =~ "Users" refute has_element?(view, "a[href='/users']", "Users") end end describe "UI authorization for Kassenwart role" do test "shows 'New Member' button", %{conn: conn} do user = create_user_with_role("Kassenwart") conn = log_in_user(conn, user) {:ok, view, html} = live(conn, ~p"/members") assert html =~ "New Member" assert has_element?(view, "a", "New Member") end test "shows Edit and Delete buttons for all members", %{conn: conn} do user = create_user_with_role("Kassenwart") member1 = create_member() member2 = create_member() conn = log_in_user(conn, user) {:ok, view, _html} = live(conn, ~p"/members") # Both members should have Edit and Delete buttons assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit") assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit") # Note: Using a more flexible selector for delete buttons assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"])) assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"])) end end describe "UI authorization for Admin role" do test "shows all navigation links", %{conn: conn} do admin = create_user_with_role("Admin") conn = log_in_user(conn, admin) {:ok, view, html} = live(conn, ~p"/") assert html =~ "Members" assert html =~ "Users" assert html =~ "Custom Fields" assert html =~ "Roles" assert has_element?(view, "a[href='/members']", "Members") assert has_element?(view, "a[href='/users']", "Users") assert has_element?(view, "a[href='/property-types']", "Custom Fields") assert has_element?(view, "a[href='/admin/roles']", "Roles") end end end ``` --- ## Future Extensions ### Phase 2: Field-Level Permissions **Timeline:** Sprint 4-5 (after Phase 1 is stable) **Goal:** Allow permission sets to restrict access to specific fields of resources. #### Database Schema (Already Prepared) The `permission_set_resources.field_name` column is already in place: - `NULL` = all fields (Phase 1 default) - `"field_name"` = specific field (Phase 2) #### Implementation Approach **Option A: Blacklist Approach (Recommended)** Permission with `field_name = NULL` grants access to all fields. Additional rows with `granted = false` deny specific fields. ```sql -- Normal User can read all Member fields INSERT INTO permission_set_resources (permission_set_id, resource_name, action, field_name, granted) VALUES (normal_user_id, 'Member', 'read', NULL, true); -- But NOT birth_date INSERT INTO permission_set_resources (permission_set_id, resource_name, action, field_name, granted) VALUES (normal_user_id, 'Member', 'read', 'birth_date', false); -- And NOT notes INSERT INTO permission_set_resources (permission_set_id, resource_name, action, field_name, granted) VALUES (normal_user_id, 'Member', 'read', 'notes', false); ``` **Evaluation Logic:** 1. Check if there's a permission with `field_name = NULL` and `granted = true` 2. If yes: Load all deny entries (`field_name != NULL` and `granted = false`) 3. Deselect those fields from query **Advantages:** - Default is "allow all fields" - Only need to specify exceptions - Easy to add new fields (automatically included) **Disadvantages:** - Can't have different scopes per field (all fields have same scope) --- **Option B: Whitelist Approach** No permission with `field_name = NULL`. Only explicit `granted = true` entries allow access. ```sql -- Read-Only can ONLY read these specific Member fields INSERT INTO permission_set_resources (permission_set_id, resource_name, action, field_name, granted) VALUES (read_only_id, 'Member', 'read', 'first_name', true), (read_only_id, 'Member', 'read', 'last_name', true), (read_only_id, 'Member', 'read', 'email', true), (read_only_id, 'Member', 'read', 'phone_number', true); -- birth_date, notes, etc. are implicitly denied ``` **Evaluation Logic:** 1. Check if there's a permission with `field_name = NULL` and `granted = true` 2. If no: Load all allow entries (`field_name != NULL` and `granted = true`) 3. Only select those fields in query **Advantages:** - Explicit "allow" model (more secure default) - Could have different scopes per field (future feature) **Disadvantages:** - Tedious to specify every allowed field - New fields are denied by default (requires permission update) --- #### Custom Preparation for Field Filtering ```elixir defmodule Mv.Authorization.Preparations.FilterFieldsByPermission do use Ash.Resource.Preparation def prepare(query, _opts, context) do actor = context.actor action = query.action resource = query.resource # Get denied fields for this actor/resource/action denied_fields = get_denied_fields(actor, resource, action.name) # Deselect denied fields Ash.Query.deselect(query, denied_fields) end defp get_denied_fields(actor, resource, action) do permission_set = get_permission_set(actor) resource_name = resource |> Module.split() |> List.last() # Query denied fields (blacklist approach) Mv.Authorization.PermissionSetResource |> Ash.Query.filter( permission_set_id == ^permission_set.id and resource_name == ^resource_name and action == ^action and not is_nil(field_name) and granted == false ) |> Ash.read!() |> Enum.map(& &1.field_name) |> Enum.map(&String.to_existing_atom/1) end end # Add to resources in Phase 2 defmodule Mv.Membership.Member do preparations do prepare Mv.Authorization.Preparations.FilterFieldsByPermission end end ``` --- #### Custom Fields (Properties) Field-Level For custom fields, field-level permissions work differently: **Approach:** `field_name` stores the PropertyType name (not Property field) ```sql -- Read-Only can ONLY see "membership_number" custom field INSERT INTO permission_set_resources (permission_set_id, resource_name, action, field_name, granted) VALUES (read_only_id, 'Property', 'read', 'membership_number', true); -- All other PropertyTypes are denied ``` **Implementation:** ```elixir defmodule Mv.Authorization.Preparations.FilterPropertiesByType do use Ash.Resource.Preparation def prepare(query, _opts, context) do actor = context.actor # Get allowed PropertyType names allowed_types = get_allowed_property_types(actor) if allowed_types == :all do query else # Filter: only load Properties of allowed types Ash.Query.filter(query, property_type.name in ^allowed_types) end end end ``` --- ### Phase 3: Payment History Permissions **Timeline:** When Payment resource is implemented **Goal:** Control access to payment-related data. #### Implementation ```sql -- Add Payment resource permissions INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) VALUES -- Own Data: Can see own payment history (optional) (own_data_id, 'Payment', 'read', 'linked', false), -- Default: disabled -- Read-Only: Cannot see payment history (read_only_id, 'Payment', 'read', 'all', false), -- Normal User (Kassenwart): Can see and edit all payment history (normal_user_id, 'Payment', 'read', 'all', true), (normal_user_id, 'Payment', 'create', 'all', true), (normal_user_id, 'Payment', 'update', 'all', true), -- Admin: Full access (admin_id, 'Payment', 'read', 'all', true), (admin_id, 'Payment', 'create', 'all', true), (admin_id, 'Payment', 'update', 'all', true), (admin_id, 'Payment', 'destroy', 'all', true); ``` **Configuration UI:** Admin UI will allow toggling "Members can view their own payment history" which updates the Own Data permission set: ```elixir # Toggle payment history visibility for members def toggle_member_payment_visibility(enabled) do own_data_ps = get_permission_set_by_name("own_data") # Find or create Payment.read permission permission = PermissionSetResource |> Ash.Query.filter( permission_set_id == ^own_data_ps.id and resource_name == "Payment" and action == "read" and scope == "linked" ) |> Ash.read_one!() # Update granted flag Ash.Changeset.for_update(permission, :update, %{granted: enabled}) |> Ash.update!() end ``` --- ### Phase 4: Groups and Group Permissions **Timeline:** TBD (future feature) **Goal:** Group members and apply permissions per group. #### Possible Approaches **Option 1: Group-scoped Permissions** Add `group_id` to permission_set_resources: ```sql ALTER TABLE permission_set_resources ADD COLUMN group_id UUID REFERENCES groups(id); -- Normal User can only edit members in "Youth Group" INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, group_id, granted) VALUES (normal_user_id, 'Member', 'update', 'all', youth_group_id, true); ``` **Option 2: Group-based Roles** Roles can have group restrictions: ```sql ALTER TABLE roles ADD COLUMN group_id UUID REFERENCES groups(id); -- "Youth Leader" role only has permissions for youth group INSERT INTO roles (name, permission_set_id, group_id) VALUES ('Youth Leader', normal_user_id, youth_group_id); ``` **Decision:** Deferred until Groups feature is designed. --- ## Migration Strategy ### Migration Plan #### Sprint 1: Foundation **Week 1:** - Create database migrations for permission tables - Create Ash resources (PermissionSet, Role, PermissionSetResource, PermissionSetPage) - Add role_id to users table - Create seed script for 4 permission sets **Week 2:** - Implement custom policy checks - Implement permission cache (ETS) - Create seeds for 5 roles with permissions - Write tests for permission evaluation #### Sprint 2: Integration **Week 3:** - Implement router plug for page permissions - Update all existing resources with policies - Handle special cases (user credentials, member email) - Integration tests for common scenarios **Week 4:** - Admin UI for role management - Admin UI for assigning roles to users - Documentation and user guide - Performance testing and optimization --- ### Data Migration #### Existing Users All existing users will be assigned the "Mitglied" (Member) role by default: ```sql -- Migration: Set default role for existing users UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL; ``` #### Backward Compatibility **Phase 1:** - No existing authorization system to maintain - Clean slate implementation **Phase 2 (Field-Level):** - Existing permission_set_resources with `field_name = NULL` continue to work - No migration needed, just add new field-specific permissions --- ## Security Considerations ### Threat Model #### 1. Privilege Escalation **Threat:** User tries to escalate privileges by manipulating requests. **Mitigation:** - All authorization enforced server-side (Ash Policies) - Actor is verified via session - No client-side permission checks that can be bypassed - Cache invalidation ensures stale permissions aren't used #### 2. Permission Cache Poisoning **Threat:** Attacker manipulates ETS cache to grant unauthorized access. **Mitigation:** - ETS table is server-side only - Cache keys include user_id (can't access other users' cache) - Cache invalidated on any permission change - Fallback to database if cache returns unexpected data #### 3. SQL Injection via Scope Filters **Threat:** Malicious actor value causes SQL injection in scope filters. **Mitigation:** - All filters use Ash's expr() macro with parameterized queries - Actor ID is always a UUID (validated by database) - No string concatenation in filter construction #### 4. Permission Set Modification **Threat:** Unauthorized user modifies permission sets or roles. **Mitigation:** - Permission Sets have `is_system = true` and cannot be deleted - Role management requires Admin permission - Audit log (future) tracks all permission changes #### 5. Bypass via Direct Database Access **Threat:** Code bypasses Ash and queries database directly. **Mitigation:** - Code review enforces "always use Ash" policy - No raw SQL in application code - Database credentials secured via environment variables #### 6. Session Hijacking **Threat:** Attacker steals session and impersonates user. **Mitigation:** - Handled by AshAuthentication (out of scope for this document) - Sessions use signed tokens - HTTPS in production --- ### Audit Logging (Future) For compliance and debugging, implement audit log: ```sql CREATE TABLE permission_audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id), action VARCHAR(50), -- "role_changed", "permission_modified" resource_type VARCHAR(255), resource_id UUID, old_value JSONB, new_value JSONB, timestamp TIMESTAMP NOT NULL DEFAULT now() ); ``` --- ## Appendix ### Glossary - **Permission Set:** A predefined collection of permissions defining what actions can be performed - **Role:** A named job function (e.g., "Treasurer") that references one permission set - **Resource:** An Ash resource (e.g., Member, User, PropertyType) - **Action:** An Ash action (e.g., read, create, update, destroy) - **Scope:** The subset of records a permission applies to (own, linked, all) - **Actor:** The current user making a request - **Page Permission:** Access control for LiveView routes - **Field-Level Permission:** Restriction on specific fields of a resource (Phase 2) ### Permission Set Summary | Permission Set | Use Case | Example Roles | Resources Access | |---------------|----------|---------------|------------------| | **own_data** | Users accessing only their own data | Mitglied | User (own), Member (linked), Property (linked) | | **read_only** | Users who can view but not edit | Vorstand, Buchhaltung | Member (all, read), Property (all, read) | | **normal_user** | Users who can edit members and properties | Kassenwart | Member (all, read/write), Property (all, read/write) | | **admin** | Full administrative access | Admin | All resources (all, full CRUD) | ### Resource Permission Matrix | Resource | Own Data | Read-Only | Normal User | Admin | |----------|----------|-----------|-------------|-------| | **Member** | Linked: R/W | All: R | All: R/W | All: Full | | **User** | Own: R/W | None | None | All: Full | | **PropertyType** | All: R | All: R | All: R | All: Full | | **Property** | Linked: R/W | All: R | All: R/W | All: Full | | **Role** | None | All: R | None | All: Full | | **Payment** (future) | Linked: R (config) | None | All: R/W | All: Full | ### Page Permission Matrix | Page Path | Own Data | Read-Only | Normal User | Admin | |-----------|----------|-----------|-------------|-------| | `/profile` | ✅ | ✅ | ✅ | ✅ | | `/members` | ❌ | ✅ | ✅ | ✅ | | `/members/:id` | ❌ | ✅ | ✅ | ✅ | | `/members/new` | ❌ | ❌ | ✅ | ✅ | | `/members/:id/edit` | ❌ | ❌ | ✅ | ✅ | | `/users` | ❌ | ❌ | ❌ | ✅ | | `/users/:id/edit` | ❌ | ❌ | ❌ | ✅ | | `/property-types` | ❌ | ✅ | ✅ | ✅ | | `/property-types/new` | ❌ | ❌ | ❌ | ✅ | | `/admin` | ❌ | ❌ | ❌ | ✅ | --- ## Document History | Version | Date | Author | Changes | |---------|------|--------|---------| | 1.0 | 2025-11-10 | Architecture Team | Initial architecture design | --- **End of Document**