diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md
new file mode 100644
index 0000000..f9de090
--- /dev/null
+++ b/docs/roles-and-permissions-architecture.md
@@ -0,0 +1,2279 @@
+# 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
+
+
+
Members
+
+
+ <%= if can?(@current_user, :create, Mv.Membership.Member) do %>
+ <.link patch={~p"/members/new"} class="btn-primary">
+ New Member
+
+ <% end %>
+