diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md
new file mode 100644
index 0000000..fa45d86
--- /dev/null
+++ b/docs/roles-and-permissions-architecture.md
@@ -0,0 +1,2502 @@
+# Roles and Permissions Architecture - Technical Specification
+
+**Version:** 2.0 (Clean Rewrite)
+**Date:** 2025-01-13
+**Status:** Ready for Implementation
+**Related Documents:**
+- [Overview](./roles-and-permissions-overview.md) - High-level concepts for stakeholders
+- [Implementation Plan](./roles-and-permissions-implementation-plan.md) - Step-by-step implementation guide
+
+---
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Requirements Analysis](#requirements-analysis)
+- [Selected Architecture](#selected-architecture)
+- [Database Schema (MVP)](#database-schema-mvp)
+- [Permission System Design (MVP)](#permission-system-design-mvp)
+- [Resource Policies](#resource-policies)
+- [Page Permission System](#page-permission-system)
+- [UI-Level Authorization](#ui-level-authorization)
+- [Special Cases](#special-cases)
+- [User-Member Linking](#user-member-linking)
+- [Future: Phase 2 - Field-Level Permissions](#future-phase-2---field-level-permissions)
+- [Future: Phase 3 - Database-Backed Permissions](#future-phase-3---database-backed-permissions)
+- [Migration Strategy](#migration-strategy)
+- [Security Considerations](#security-considerations)
+- [Appendix](#appendix)
+
+---
+
+## Overview
+
+This document provides the complete technical specification for the **Roles and Permissions system** in the Mila membership management application. The system controls who can access what data and which actions they can perform.
+
+### Key Design Principles
+
+1. **Security First:** Authorization is enforced at multiple layers (database policies, page access, UI rendering)
+2. **Performance:** MVP uses hardcoded permissions for < 1 microsecond checks
+3. **Maintainability:** Clear separation between roles (data) and permissions (logic)
+4. **Extensibility:** Clean migration path to database-backed permissions (Phase 3)
+5. **User Experience:** Consistent authorization across backend and frontend
+6. **Test-Driven:** All components fully tested with behavior-focused tests
+
+### Architecture Approach
+
+**MVP (Phase 1) - Hardcoded Permission Sets:**
+- Permission logic in Elixir module (`Mv.Authorization.PermissionSets`)
+- Role data in database (`roles` table)
+- Roles reference permission sets by name (string)
+- Zero database queries for permission checks
+- Implementation time: 2-3 weeks
+
+**Future (Phase 2) - Field-Level Permissions:**
+- Extend PermissionSets with field-level granularity
+- Ash Calculations for read filtering
+- Custom Validations for write protection
+- No database schema changes
+
+**Future (Phase 3) - Database-Backed Permissions:**
+- Move permission data to database tables
+- Runtime permission configuration
+- ETS cache for performance
+- Migration from hardcoded module
+
+---
+
+## Requirements Analysis
+
+### Core Requirements
+
+**1. Predefined Permission Sets**
+
+Four hardcoded permission sets that define access patterns:
+
+- **own_data** - User can only access their own data (default for members)
+- **read_only** - Read access to all member data, no modifications
+- **normal_user** - Create/Read/Update on members (no delete), full CRUD on custom fields
+- **admin** - Unrestricted access including user/role management
+
+**2. Roles Stored in Database**
+
+Five predefined roles stored in the `roles` table:
+
+- **Mitglied** (Member) → uses "own_data" permission set
+- **Vorstand** (Board) → uses "read_only" permission set
+- **Kassenwart** (Treasurer) → uses "normal_user" permission set
+- **Buchhaltung** (Accounting) → uses "read_only" permission set
+- **Admin** → uses "admin" permission set
+
+**3. Resource-Level Permissions**
+
+Control CRUD operations on:
+- User (credentials, profile)
+- Member (member data)
+- Property (custom field values)
+- PropertyType (custom field definitions)
+- Role (role management)
+
+**4. Page-Level Permissions**
+
+Control access to LiveView pages:
+- Index pages (list views)
+- Show pages (detail views)
+- Form pages (create/edit)
+- Admin pages
+
+**5. Granular Scopes**
+
+Three scope levels for permissions:
+- **:own** - Only records where `record.id == user.id` (for User resource)
+- **:linked** - Only records linked to user via relationships
+ - Member: `member.user_id == user.id`
+ - Property: `property.member.user_id == user.id`
+- **:all** - All records, no filtering
+
+**6. Special Cases**
+
+- **Own Credentials:** Every user can always read/update their own credentials
+- **Linked Member Email:** Only admins can edit email of member linked to user
+- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
+- **User-Member Linking:** Only admins can link/unlink users and members
+
+**7. UI Consistency**
+
+- UI elements (buttons, links) only shown if user has permission
+- Page access controlled before LiveView mounts
+- Consistent authorization logic between backend and frontend
+
+---
+
+## Selected Architecture
+
+### Approach: Hardcoded Permission Sets with Database Roles
+
+**Core Concept:**
+
+```
+PermissionSets Module (hardcoded in code)
+ ↓ (referenced by permission_set_name)
+Role (stored in DB: "Vorstand" → "read_only")
+ ↓ (assigned to user via role_id)
+User (each user has one role)
+```
+
+**Why This Approach?**
+
+✅ **Fast Implementation:** 2-3 weeks vs. 4-5 weeks for DB-backed
+✅ **Maximum Performance:** < 1 microsecond per check (pure function call)
+✅ **Zero DB Overhead:** No permission queries, no joins, no cache needed
+✅ **Git-Tracked Changes:** All permission changes in version control
+✅ **Deterministic Testing:** No DB setup, purely functional tests
+✅ **Clear Migration Path:** Well-defined Phase 3 for DB-backed permissions
+
+**Trade-offs:**
+
+⚠️ **Deployment Required:** Permission changes need code deployment
+⚠️ **Four Fixed Sets:** Cannot add new permission sets without code change
+✔️ **Acceptable for MVP:** Requirements specify 4 fixed sets, rare changes expected
+
+### System Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Authorization System │
+└─────────────────────────────────────────────────────────────┘
+
+┌──────────────────┐
+│ LiveView │
+│ (UI Layer) │
+└────────┬─────────┘
+ │
+ │ 1. Page Access Check
+ ↓
+┌──────────────────────────────────┐
+│ CheckPagePermission Plug │
+│ - Reads PermissionSets module │
+│ - Matches page pattern │
+│ - Redirects if unauthorized │
+└────────┬─────────────────────────┘
+ │
+ │ 2. UI Element Check
+ ↓
+┌──────────────────────────────────┐
+│ MvWeb.Authorization │
+│ - can?/3 │
+│ - can_access_page?/2 │
+│ - Uses PermissionSets module │
+└────────┬─────────────────────────┘
+ │
+ │ 3. Resource Action
+ ↓
+┌──────────────────────────────────┐
+│ Ash Resource (Member, User...) │
+│ - Policies block │
+└────────┬─────────────────────────┘
+ │
+ │ 4. Policy Evaluation
+ ↓
+┌──────────────────────────────────┐
+│ HasPermission Policy Check │
+│ - Reads actor.role │
+│ - Calls PermissionSets.get_permissions/1 │
+│ - Applies scope filter │
+└────────┬─────────────────────────┘
+ │
+ │ 5. Permission Lookup
+ ↓
+┌──────────────────────────────────┐
+│ PermissionSets Module │
+│ (Hardcoded in code) │
+│ - get_permissions/1 │
+│ - Returns {resources, pages} │
+└──────────────────────────────────┘
+
+┌──────────────────────────────────┐
+│ Database │
+│ - roles table │
+│ - users.role_id → roles.id │
+└──────────────────────────────────┘
+```
+
+**Authorization Flow:**
+
+1. **Page Request:** Plug checks if user can access page
+2. **UI Rendering:** Helper checks which buttons/links to show
+3. **User Action:** Ash receives action request (create, read, update, destroy)
+4. **Policy Check:** `HasPermission` evaluates permission
+5. **Permission Lookup:** Reads from `PermissionSets` module (in-memory)
+6. **Scope Application:** Filters query based on scope (:own, :linked, :all)
+7. **Result:** Action succeeds or fails with Forbidden error
+
+---
+
+## Database Schema (MVP)
+
+### Overview
+
+The MVP requires **only ONE new table**: `roles`
+
+- ✅ Stores role definitions (name, description, permission_set_name)
+- ✅ Links to users via foreign key
+- ❌ NO permission tables (permissions are hardcoded)
+
+### Entity Relationship Diagram
+
+```
+┌─────────────────────────────────┐
+│ users │
+├─────────────────────────────────┤
+│ id (PK, UUID) │
+│ email │
+│ hashed_password │
+│ role_id (FK → roles.id) ◄───┼──┐
+│ ... │ │
+└─────────────────────────────────┘ │
+ │
+ │
+┌─────────────────────────────────┐ │
+│ roles │ │
+├─────────────────────────────────┤ │
+│ id (PK, UUID) │──┘
+│ name (unique) │
+│ description │
+│ permission_set_name (String) │───┐
+│ is_system_role (Boolean) │ │
+│ inserted_at │ │
+│ updated_at │ │
+└─────────────────────────────────┘ │
+ │
+ │ References one of:
+┌─────────────────────────────────┐ │ - "own_data"
+│ PermissionSets Module │◄──┘ - "read_only"
+│ (Hardcoded in Code) │ - "normal_user"
+├─────────────────────────────────┤ - "admin"
+│ get_permissions(:own_data) │
+│ get_permissions(:read_only) │
+│ get_permissions(:normal_user) │
+│ get_permissions(:admin) │
+└─────────────────────────────────┘
+```
+
+### Table Definitions
+
+#### roles
+
+Stores role definitions that reference permission sets by name.
+
+```sql
+CREATE TABLE roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL UNIQUE,
+ description TEXT,
+ permission_set_name VARCHAR(50) NOT NULL,
+ is_system_role BOOLEAN NOT NULL DEFAULT false,
+ inserted_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP NOT NULL DEFAULT now(),
+
+ CONSTRAINT check_valid_permission_set
+ CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin'))
+);
+
+CREATE UNIQUE INDEX roles_name_index ON roles (name);
+CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name);
+```
+
+**Fields:**
+- `name` - Display name (e.g., "Vorstand", "Admin")
+- `description` - Human-readable description
+- `permission_set_name` - References hardcoded permission set
+- `is_system_role` - If true, role cannot be deleted (protects "Mitglied")
+
+**Constraints:**
+- `name` must be unique
+- `permission_set_name` must be one of 4 valid values
+- System roles cannot be deleted (enforced in Ash resource)
+
+#### users (modified)
+
+Add foreign key to roles table.
+
+```sql
+ALTER TABLE users
+ ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT;
+
+CREATE INDEX users_role_id_index ON users (role_id);
+```
+
+**ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it.
+
+### Seed Data
+
+Five predefined roles created during initial setup:
+
+```elixir
+# priv/repo/seeds/authorization_seeds.exs
+
+roles = [
+ %{
+ name: "Mitglied",
+ description: "Default member role with access to own data only",
+ permission_set_name: "own_data",
+ is_system_role: true # Cannot be deleted!
+ },
+ %{
+ name: "Vorstand",
+ description: "Board member with read access to all member data",
+ permission_set_name: "read_only",
+ is_system_role: false
+ },
+ %{
+ name: "Kassenwart",
+ description: "Treasurer with full member and payment management",
+ permission_set_name: "normal_user",
+ is_system_role: false
+ },
+ %{
+ name: "Buchhaltung",
+ description: "Accounting with read-only access for auditing",
+ permission_set_name: "read_only",
+ is_system_role: false
+ },
+ %{
+ name: "Admin",
+ description: "Administrator with unrestricted access",
+ permission_set_name: "admin",
+ is_system_role: false
+ }
+]
+
+# Create roles with idempotent logic
+Enum.each(roles, fn role_data ->
+ case Ash.get(Mv.Authorization.Role, name: role_data.name) do
+ {:ok, existing_role} ->
+ # Update if exists
+ Ash.update!(existing_role, role_data)
+ {:error, _} ->
+ # Create if not exists
+ Ash.create!(Mv.Authorization.Role, role_data)
+ end
+end)
+
+# Assign "Mitglied" role to users without role
+mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied")
+users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id)))
+
+Enum.each(users_without_role, fn user ->
+ Ash.update!(user, %{role_id: mitglied_role.id})
+end)
+```
+
+---
+
+## Permission System Design (MVP)
+
+### PermissionSets Module
+
+**Location:** `lib/mv/authorization/permission_sets.ex`
+
+This module is the **single source of truth** for all permissions in the MVP. It defines what each permission set can do.
+
+#### Module Structure
+
+```elixir
+defmodule Mv.Authorization.PermissionSets do
+ @moduledoc """
+ Defines the four hardcoded permission sets for the application.
+
+ Each permission set specifies:
+ - Resource permissions (what CRUD operations on which resources)
+ - Page permissions (which LiveView pages can be accessed)
+ - Scopes (own, linked, all)
+
+ ## Permission Sets
+
+ 1. **own_data** - Default for "Mitglied" role
+ - Can only access own user data and linked member/properties
+ - Cannot create new members or manage system
+
+ 2. **read_only** - For "Vorstand" and "Buchhaltung" roles
+ - Can read all member data
+ - Cannot create, update, or delete
+
+ 3. **normal_user** - For "Kassenwart" role
+ - Create/Read/Update members (no delete), full CRUD on properties
+ - Cannot manage property types or users
+
+ 4. **admin** - For "Admin" role
+ - Unrestricted access to all resources
+ - Can manage users, roles, property types
+
+ ## Usage
+
+ # Get permissions for a role's permission set
+ permissions = PermissionSets.get_permissions(:admin)
+
+ # Check if a permission set name is valid
+ PermissionSets.valid_permission_set?("read_only") # => true
+
+ # Convert string to atom safely
+ {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
+
+ ## Performance
+
+ All functions are pure and compile-time. Permission lookups are < 1 microsecond.
+ """
+
+ @type scope :: :own | :linked | :all
+ @type action :: :read | :create | :update | :destroy
+
+ @type resource_permission :: %{
+ resource: String.t(),
+ action: action(),
+ scope: scope(),
+ granted: boolean()
+ }
+
+ @type permission_set :: %{
+ resources: [resource_permission()],
+ pages: [String.t()]
+ }
+
+ @doc """
+ Returns the list of all valid permission set names.
+
+ ## Examples
+
+ iex> PermissionSets.all_permission_sets()
+ [:own_data, :read_only, :normal_user, :admin]
+ """
+ @spec all_permission_sets() :: [atom()]
+ def all_permission_sets do
+ [:own_data, :read_only, :normal_user, :admin]
+ end
+
+ @doc """
+ Returns permissions for the given permission set.
+
+ ## Examples
+
+ iex> permissions = PermissionSets.get_permissions(:admin)
+ iex> Enum.any?(permissions.resources, fn p ->
+ ...> p.resource == "User" and p.action == :destroy
+ ...> end)
+ true
+
+ iex> PermissionSets.get_permissions(:invalid)
+ ** (FunctionClauseError) no function clause matching
+ """
+ @spec get_permissions(atom()) :: permission_set()
+
+ def get_permissions(:own_data) do
+ %{
+ resources: [
+ # User: Can always read/update own credentials
+ %{resource: "User", action: :read, scope: :own, granted: true},
+ %{resource: "User", action: :update, scope: :own, granted: true},
+
+ # Member: Can read/update linked member
+ %{resource: "Member", action: :read, scope: :linked, granted: true},
+ %{resource: "Member", action: :update, scope: :linked, granted: true},
+
+ # Property: Can read/update properties of linked member
+ %{resource: "Property", action: :read, scope: :linked, granted: true},
+ %{resource: "Property", action: :update, scope: :linked, granted: true},
+
+ # PropertyType: Can read all (needed for forms)
+ %{resource: "PropertyType", action: :read, scope: :all, granted: true}
+ ],
+ pages: [
+ "/", # Home page
+ "/profile", # Own profile
+ "/members/:id" # Linked member detail (filtered by policy)
+ ]
+ }
+ end
+
+ def get_permissions(:read_only) do
+ %{
+ resources: [
+ # User: Can read/update own credentials only
+ %{resource: "User", action: :read, scope: :own, granted: true},
+ %{resource: "User", action: :update, scope: :own, granted: true},
+
+ # Member: Can read all members, no modifications
+ %{resource: "Member", action: :read, scope: :all, granted: true},
+
+ # Property: Can read all properties
+ %{resource: "Property", action: :read, scope: :all, granted: true},
+
+ # PropertyType: Can read all
+ %{resource: "PropertyType", action: :read, scope: :all, granted: true}
+ ],
+ pages: [
+ "/",
+ "/members", # Member list
+ "/members/:id", # Member detail
+ "/properties", # Property overview
+ "/profile" # Own profile
+ ]
+ }
+ end
+
+ def get_permissions(:normal_user) do
+ %{
+ resources: [
+ # User: Can read/update own credentials only
+ %{resource: "User", action: :read, scope: :own, granted: true},
+ %{resource: "User", action: :update, scope: :own, granted: true},
+
+ # Member: Full CRUD
+ %{resource: "Member", action: :read, scope: :all, granted: true},
+ %{resource: "Member", action: :create, scope: :all, granted: true},
+ %{resource: "Member", action: :update, scope: :all, granted: true},
+ # Note: destroy intentionally omitted for safety
+
+ # Property: Full CRUD
+ %{resource: "Property", action: :read, scope: :all, granted: true},
+ %{resource: "Property", action: :create, scope: :all, granted: true},
+ %{resource: "Property", action: :update, scope: :all, granted: true},
+ %{resource: "Property", action: :destroy, scope: :all, granted: true},
+
+ # PropertyType: Read only (admin manages definitions)
+ %{resource: "PropertyType", action: :read, scope: :all, granted: true}
+ ],
+ pages: [
+ "/",
+ "/members",
+ "/members/new", # Create member
+ "/members/:id",
+ "/members/:id/edit", # Edit member
+ "/properties",
+ "/properties/new",
+ "/properties/:id/edit",
+ "/profile"
+ ]
+ }
+ end
+
+ def get_permissions(:admin) do
+ %{
+ resources: [
+ # User: Full management including other users
+ %{resource: "User", action: :read, scope: :all, granted: true},
+ %{resource: "User", action: :create, scope: :all, granted: true},
+ %{resource: "User", action: :update, scope: :all, granted: true},
+ %{resource: "User", action: :destroy, scope: :all, granted: true},
+
+ # Member: Full CRUD
+ %{resource: "Member", action: :read, scope: :all, granted: true},
+ %{resource: "Member", action: :create, scope: :all, granted: true},
+ %{resource: "Member", action: :update, scope: :all, granted: true},
+ %{resource: "Member", action: :destroy, scope: :all, granted: true},
+
+ # Property: Full CRUD
+ %{resource: "Property", action: :read, scope: :all, granted: true},
+ %{resource: "Property", action: :create, scope: :all, granted: true},
+ %{resource: "Property", action: :update, scope: :all, granted: true},
+ %{resource: "Property", action: :destroy, scope: :all, granted: true},
+
+ # PropertyType: Full CRUD (admin manages custom field definitions)
+ %{resource: "PropertyType", action: :read, scope: :all, granted: true},
+ %{resource: "PropertyType", action: :create, scope: :all, granted: true},
+ %{resource: "PropertyType", action: :update, scope: :all, granted: true},
+ %{resource: "PropertyType", action: :destroy, scope: :all, granted: true},
+
+ # Role: Full CRUD (admin manages roles)
+ %{resource: "Role", action: :read, scope: :all, granted: true},
+ %{resource: "Role", action: :create, scope: :all, granted: true},
+ %{resource: "Role", action: :update, scope: :all, granted: true},
+ %{resource: "Role", action: :destroy, scope: :all, granted: true}
+ ],
+ pages: [
+ "*" # Wildcard: Admin can access all pages
+ ]
+ }
+ end
+
+ @doc """
+ Checks if a permission set name (string or atom) is valid.
+
+ ## Examples
+
+ iex> PermissionSets.valid_permission_set?("admin")
+ true
+
+ iex> PermissionSets.valid_permission_set?(:read_only)
+ true
+
+ iex> PermissionSets.valid_permission_set?("invalid")
+ false
+ """
+ @spec valid_permission_set?(String.t() | atom()) :: boolean()
+ def valid_permission_set?(name) when is_binary(name) do
+ case permission_set_name_to_atom(name) do
+ {:ok, _atom} -> true
+ {:error, _} -> false
+ end
+ end
+
+ def valid_permission_set?(name) when is_atom(name) do
+ name in all_permission_sets()
+ end
+
+ @doc """
+ Converts a permission set name string to atom safely.
+
+ ## Examples
+
+ iex> PermissionSets.permission_set_name_to_atom("admin")
+ {:ok, :admin}
+
+ iex> PermissionSets.permission_set_name_to_atom("invalid")
+ {:error, :invalid_permission_set}
+ """
+ @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set}
+ def permission_set_name_to_atom(name) when is_binary(name) do
+ atom = String.to_existing_atom(name)
+ if valid_permission_set?(atom) do
+ {:ok, atom}
+ else
+ {:error, :invalid_permission_set}
+ end
+ rescue
+ ArgumentError -> {:error, :invalid_permission_set}
+ end
+end
+```
+
+#### Permission Matrix
+
+Quick reference table showing what each permission set allows:
+
+| Resource | own_data | read_only | normal_user | admin |
+|----------|----------|-----------|-------------|-------|
+| **User** (own) | R, U | R, U | R, U | R, U |
+| **User** (all) | - | - | - | R, C, U, D |
+| **Member** (linked) | R, U | - | - | - |
+| **Member** (all) | - | R | R, C, U | R, C, U, D |
+| **Property** (linked) | R, U | - | - | - |
+| **Property** (all) | - | R | R, C, U, D | R, C, U, D |
+| **PropertyType** (all) | R | R | R | R, C, U, D |
+| **Role** (all) | - | - | - | R, C, U, D |
+
+**Legend:** R=Read, C=Create, U=Update, D=Destroy
+
+### HasPermission Policy Check
+
+**Location:** `lib/mv/authorization/checks/has_permission.ex`
+
+This is a custom Ash Policy Check that evaluates permissions from the `PermissionSets` module.
+
+```elixir
+defmodule Mv.Authorization.Checks.HasPermission do
+ @moduledoc """
+ Custom Ash Policy Check that evaluates permissions from the PermissionSets module.
+
+ This check:
+ 1. Reads the actor's role and permission_set_name
+ 2. Looks up permissions from PermissionSets.get_permissions/1
+ 3. Finds matching permission for current resource + action
+ 4. Applies scope filter (:own, :linked, :all)
+
+ ## Usage in Ash Resource
+
+ policies do
+ policy action_type(:read) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+ end
+
+ ## Scope Behavior
+
+ - **:all** - Authorizes without filtering (returns all records)
+ - **:own** - Filters to records where record.id == actor.id
+ - **:linked** - Filters based on resource type:
+ - Member: member.user_id == actor.id
+ - Property: property.member.user_id == actor.id (traverses relationship!)
+
+ ## Error Handling
+
+ Returns `{:error, reason}` for:
+ - Missing actor
+ - Actor without role
+ - Invalid permission_set_name
+ - No matching permission found
+
+ All errors result in Forbidden (policy fails).
+ """
+
+ use Ash.Policy.Check
+ require Ash.Query
+ import Ash.Expr
+ alias Mv.Authorization.PermissionSets
+
+ @impl true
+ def describe(_opts) do
+ "checks if actor has permission via their role's permission set"
+ end
+
+ @impl true
+ def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do
+ with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom),
+ resource_name <- get_resource_name(resource) do
+ check_permission(permissions.resources, resource_name, action, actor, resource_name)
+ else
+ %{role: nil} ->
+ log_auth_failure(actor, resource, action, "no role assigned")
+ {:error, :no_role}
+
+ %{role: %{permission_set_name: nil}} ->
+ log_auth_failure(actor, resource, action, "role has no permission_set_name")
+ {:error, :no_permission_set}
+
+ {:error, :invalid_permission_set} = error ->
+ log_auth_failure(actor, resource, action, "invalid permission_set_name")
+ error
+
+ _ ->
+ log_auth_failure(actor, resource, action, "no actor or missing data")
+ {:error, :no_permission}
+ end
+ end
+
+ # Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
+ defp get_resource_name(resource) when is_atom(resource) do
+ resource |> Module.split() |> List.last()
+ end
+
+ # Find matching permission and apply scope
+ defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do
+ case Enum.find(resource_perms, fn perm ->
+ perm.resource == resource_name and
+ perm.action == action and
+ perm.granted
+ end) do
+ nil ->
+ {:error, :no_permission}
+
+ perm ->
+ apply_scope(perm.scope, actor, resource_name)
+ end
+ end
+
+ # Scope: all - No filtering, access to all records
+ defp apply_scope(:all, _actor, _resource) do
+ :authorized
+ end
+
+ # Scope: own - Filter to records where record.id == actor.id
+ # Used for User resource (users can access their own user record)
+ defp apply_scope(:own, actor, _resource) do
+ {:filter, expr(id == ^actor.id)}
+ end
+
+ # Scope: linked - Filter based on user_id relationship (resource-specific!)
+ defp apply_scope(:linked, actor, resource_name) do
+ case resource_name do
+ "Member" ->
+ # Member.user_id == actor.id (direct relationship)
+ {:filter, expr(user_id == ^actor.id)}
+
+ "Property" ->
+ # Property.member.user_id == actor.id (traverse through member!)
+ {:filter, expr(member.user_id == ^actor.id)}
+
+ _ ->
+ # Fallback for other resources: try direct user_id
+ {:filter, expr(user_id == ^actor.id)}
+ end
+ end
+
+ # Log authorization failures for debugging
+ defp log_auth_failure(actor, resource, action, reason) do
+ require Logger
+
+ actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
+ resource_name = get_resource_name(resource)
+
+ Logger.debug("""
+ Authorization failed:
+ Actor: #{actor_id}
+ Resource: #{resource_name}
+ Action: #{action}
+ Reason: #{reason}
+ """)
+ end
+end
+```
+
+**Key Design Decisions:**
+
+1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id`
+2. **Error Handling:** All errors log for debugging but return generic forbidden to user
+3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings
+4. **Pure Function:** No side effects, deterministic, easily testable
+
+---
+
+## Resource Policies
+
+Each Ash resource defines policies that use the `HasPermission` check. This section documents the policy structure for each resource.
+
+### General Policy Pattern
+
+**All resources follow this pattern:**
+
+```elixir
+policies do
+ # 1. Special cases first (most specific)
+ policy action_type(:read) do
+ authorize_if expr(condition_for_special_case)
+ end
+
+ # 2. General authorization (uses PermissionSets)
+ policy action_type([:read, :create, :update, :destroy]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # 3. Default: Forbid
+ policy action_type([:read, :create, :update, :destroy]) do
+ forbid_if always()
+ end
+end
+```
+
+**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins.
+
+### User Resource Policies
+
+**Location:** `lib/mv/accounts/user.ex`
+
+**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role.
+
+```elixir
+defmodule Mv.Accounts.User do
+ use Ash.Resource, ...
+
+ policies do
+ # SPECIAL CASE: Users can always access their own account
+ # This takes precedence over permission checks
+ policy action_type([:read, :update]) do
+ description "Users can always read and update their own account"
+ authorize_if expr(id == ^actor(:id))
+ end
+
+ # GENERAL: Other operations require permission
+ # (e.g., admin reading/updating other users, admin destroying users)
+ policy action_type([:read, :create, :update, :destroy]) do
+ description "Check permissions from user's role"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # DEFAULT: Forbid if no policy matched
+ policy action_type([:read, :create, :update, :destroy]) do
+ forbid_if always()
+ end
+ end
+
+ # ...
+end
+```
+
+**Permission Matrix:**
+
+| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
+|--------|----------|----------|------------|-------------|-------|
+| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
+| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
+| Read others | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Update others | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |
+
+### Member Resource Policies
+
+**Location:** `lib/mv/membership/member.ex`
+
+**Special Case:** Users can always access their linked member (where `member.user_id == user.id`).
+
+```elixir
+defmodule Mv.Membership.Member do
+ use Ash.Resource, ...
+
+ policies do
+ # SPECIAL CASE: Users can always access their linked member
+ policy action_type([:read, :update]) do
+ description "Users can access member linked to their account"
+ authorize_if expr(user_id == ^actor(:id))
+ end
+
+ # GENERAL: Check permissions from role
+ policy action_type([:read, :create, :update, :destroy]) do
+ description "Check permissions from user's role"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # DEFAULT: Forbid
+ policy action_type([:read, :create, :update, :destroy]) do
+ forbid_if always()
+ end
+ end
+
+ # Custom validation for email editing (see Special Cases section)
+ validations do
+ validate changing(:email), on: :update do
+ validate &validate_linked_member_email_change/2
+ end
+ end
+
+ # ...
+end
+```
+
+**Permission Matrix:**
+
+| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
+|--------|----------|----------|------------|-------------|-------|
+| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ |
+| Update linked | ✅ (special)* | ❌ | ✅* | ❌ | ✅ |
+| Read all | ❌ | ✅ | ✅ | ✅ | ✅ |
+| Create | ❌ | ❌ | ✅ | ❌ | ✅ |
+| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |
+
+*Email editing has additional validation (see Special Cases)
+
+### Property Resource Policies
+
+**Location:** `lib/mv/membership/property.ex`
+
+**Special Case:** Users can access properties of their linked member.
+
+```elixir
+defmodule Mv.Membership.Property do
+ use Ash.Resource, ...
+
+ policies do
+ # SPECIAL CASE: Users can access properties of their linked member
+ # Note: This traverses the member relationship!
+ policy action_type([:read, :update]) do
+ description "Users can access properties of their linked member"
+ authorize_if expr(member.user_id == ^actor(:id))
+ end
+
+ # GENERAL: Check permissions from role
+ policy action_type([:read, :create, :update, :destroy]) do
+ description "Check permissions from user's role"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # DEFAULT: Forbid
+ policy action_type([:read, :create, :update, :destroy]) do
+ forbid_if always()
+ end
+ end
+
+ # ...
+end
+```
+
+**Permission Matrix:**
+
+| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
+|--------|----------|----------|------------|-------------|-------|
+| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ |
+| Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ |
+| Read all | ❌ | ✅ | ✅ | ✅ | ✅ |
+| Create | ❌ | ❌ | ✅ | ❌ | ✅ |
+| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ |
+
+### PropertyType Resource Policies
+
+**Location:** `lib/mv/membership/property_type.ex`
+
+**No Special Cases:** All users can read, only admin can write.
+
+```elixir
+defmodule Mv.Membership.PropertyType do
+ use Ash.Resource, ...
+
+ policies do
+ # All authenticated users can read property types (needed for forms)
+ # Write operations are admin-only
+ policy action_type([:read, :create, :update, :destroy]) do
+ description "Check permissions from user's role"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # DEFAULT: Forbid
+ policy action_type([:read, :create, :update, :destroy]) do
+ forbid_if always()
+ end
+ end
+
+ # ...
+end
+```
+
+**Permission Matrix:**
+
+| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
+|--------|----------|----------|------------|-------------|-------|
+| Read | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |
+
+### Role Resource Policies
+
+**Location:** `lib/mv/authorization/role.ex`
+
+**Special Protection:** System roles cannot be deleted.
+
+```elixir
+defmodule Mv.Authorization.Role do
+ use Ash.Resource, ...
+
+ policies do
+ # Only admin can manage roles
+ policy action_type([:read, :create, :update, :destroy]) do
+ description "Check permissions from user's role"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # DEFAULT: Forbid
+ policy action_type([:read, :create, :update, :destroy]) do
+ forbid_if always()
+ end
+ end
+
+ # Prevent deletion of system roles
+ validations do
+ validate action(:destroy) do
+ validate fn _changeset, %{data: role} ->
+ if role.is_system_role do
+ {:error, "Cannot delete system role"}
+ else
+ :ok
+ end
+ end
+ end
+ end
+
+ # Validate permission_set_name
+ validations do
+ validate attribute(:permission_set_name) do
+ validate fn _changeset, value ->
+ if PermissionSets.valid_permission_set?(value) do
+ :ok
+ else
+ {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"}
+ end
+ end
+ end
+ end
+
+ # ...
+end
+```
+
+**Permission Matrix:**
+
+| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
+|--------|----------|----------|------------|-------------|-------|
+| Read | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
+| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ |
+
+*Cannot destroy if `is_system_role=true`
+
+---
+
+## Page Permission System
+
+Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug.
+
+### CheckPagePermission Plug
+
+**Location:** `lib/mv_web/plugs/check_page_permission.ex`
+
+This plug runs in the router pipeline and checks if the current user has permission to access the requested page.
+
+```elixir
+defmodule MvWeb.Plugs.CheckPagePermission do
+ @moduledoc """
+ Plug that checks if current user has permission to access the current page.
+
+ ## How It Works
+
+ 1. Extracts page path from conn (route template like "/members/:id")
+ 2. Gets current user from conn.assigns
+ 3. Gets user's permission_set_name from role
+ 4. Calls PermissionSets.get_permissions/1 to get allowed pages
+ 5. Matches requested path against allowed patterns
+ 6. If unauthorized: redirects to "/" with flash error
+
+ ## Pattern Matching
+
+ - Exact match: "/members" == "/members"
+ - Dynamic routes: "/members/:id" matches "/members/123"
+ - Wildcard: "*" matches everything (admin)
+
+ ## Usage in Router
+
+ pipeline :require_page_permission do
+ plug MvWeb.Plugs.CheckPagePermission
+ end
+
+ scope "/members", MvWeb do
+ pipe_through [:browser, :require_authenticated_user, :require_page_permission]
+
+ live "/", MemberLive.Index
+ live "/:id", MemberLive.Show
+ end
+ """
+
+ import Plug.Conn
+ import Phoenix.Controller
+ alias Mv.Authorization.PermissionSets
+ require Logger
+
+ 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
+ log_page_access_denied(user, page_path)
+
+ conn
+ |> put_flash(:error, "You don't have permission to access this page.")
+ |> redirect(to: "/")
+ |> halt()
+ end
+ end
+
+ # Extract page path from conn (route template preferred, fallback to request_path)
+ defp get_page_path(conn) do
+ case conn.private[:phoenix_route] do
+ {_plug, _opts, _pipe, route_template, _meta} ->
+ route_template
+
+ _ ->
+ conn.request_path
+ end
+ end
+
+ # Check if user has permission for page
+ defp has_page_permission?(nil, _page_path) do
+ false
+ end
+
+ defp has_page_permission?(user, page_path) do
+ with %{role: %{permission_set_name: ps_name}} <- user,
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom) do
+ page_matches?(permissions.pages, page_path)
+ else
+ _ -> false
+ end
+ end
+
+ # Check if requested path matches any allowed pattern
+ defp page_matches?(allowed_pages, requested_path) do
+ Enum.any?(allowed_pages, fn pattern ->
+ cond do
+ # Wildcard: admin can access all pages
+ pattern == "*" ->
+ true
+
+ # Exact match
+ pattern == requested_path ->
+ true
+
+ # Dynamic route match (e.g., "/members/:id" matches "/members/123")
+ String.contains?(pattern, ":") ->
+ match_dynamic_route?(pattern, requested_path)
+
+ # No match
+ true ->
+ false
+ end
+ end)
+ end
+
+ # Match dynamic route pattern against actual path
+ defp match_dynamic_route?(pattern, path) do
+ pattern_segments = String.split(pattern, "/", trim: true)
+ path_segments = String.split(path, "/", trim: true)
+
+ # Must have same number of segments
+ if length(pattern_segments) == length(path_segments) do
+ Enum.zip(pattern_segments, path_segments)
+ |> Enum.all?(fn {pattern_seg, path_seg} ->
+ # Dynamic segment (starts with :) matches anything
+ String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
+ end)
+ else
+ false
+ end
+ end
+
+ defp log_page_access_denied(user, page_path) do
+ user_id = if is_map(user), do: Map.get(user, :id), else: "nil"
+ role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil"
+
+ Logger.info("""
+ Page access denied:
+ User: #{user_id}
+ Role: #{role}
+ Page: #{page_path}
+ """)
+ end
+end
+```
+
+### Router Integration
+
+Add plug to protected routes:
+
+```elixir
+defmodule MvWeb.Router do
+ use MvWeb, :router
+
+ pipeline :require_page_permission do
+ plug MvWeb.Plugs.CheckPagePermission
+ end
+
+ # Public routes (no authentication)
+ scope "/", MvWeb do
+ pipe_through :browser
+
+ live "/", PageController, :home
+ get "/login", AuthController, :new
+ post "/login", AuthController, :create
+ end
+
+ # Protected routes (authentication + page permission)
+ scope "/members", MvWeb do
+ pipe_through [:browser, :require_authenticated_user, :require_page_permission]
+
+ live "/", MemberLive.Index, :index
+ live "/new", MemberLive.Form, :new
+ live "/:id", MemberLive.Show, :show
+ live "/:id/edit", MemberLive.Form, :edit
+ end
+
+ # Admin routes
+ scope "/admin", MvWeb do
+ pipe_through [:browser, :require_authenticated_user, :require_page_permission]
+
+ live "/roles", RoleLive.Index, :index
+ live "/roles/:id", RoleLive.Show, :show
+ end
+end
+```
+
+### Page Permission Examples
+
+**Mitglied (own_data):**
+- ✅ Can access: `/`, `/profile`, `/members/123` (if 123 is their linked member)
+- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles`
+
+**Vorstand (read_only):**
+- ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile`
+- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles`
+
+**Kassenwart (normal_user):**
+- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile`
+- ❌ Cannot access: `/admin/roles`, `/admin/property_types/new`
+
+**Admin:**
+- ✅ Can access: `*` (all pages, including `/admin/roles`)
+
+---
+
+## UI-Level Authorization
+
+UI-level authorization ensures that users only see buttons, links, and form fields they have permission to use. This provides a consistent user experience and prevents confusing "forbidden" errors.
+
+### MvWeb.Authorization Helper Module
+
+**Location:** `lib/mv_web/authorization.ex`
+
+This module provides helper functions for conditional rendering in LiveView templates.
+
+```elixir
+defmodule MvWeb.Authorization do
+ @moduledoc """
+ UI-level authorization helpers for LiveView templates.
+
+ These functions check if the current user has permission to perform actions
+ or access pages. They use the same PermissionSets module as the backend policies,
+ ensuring UI and backend authorization are consistent.
+
+ ## Usage in Templates
+
+
+ <%= if can?(@current_user, :create, Mv.Membership.Member) do %>
+ <.link patch={~p"/members/new"}>New Member
+ <% end %>
+
+
+ <%= if can?(@current_user, :update, @member) do %>
+ <.button>Edit
+ <% end %>
+
+
+ <%= if can_access_page?(@current_user, "/admin/roles") do %>
+ <.link navigate="/admin/roles">Manage Roles
+ <% end %>
+
+ ## Performance
+
+ All checks are pure function calls using the hardcoded PermissionSets module.
+ No database queries, < 1 microsecond per check.
+ """
+
+ alias Mv.Authorization.PermissionSets
+
+ @doc """
+ Checks if user has permission for an action on a resource (atom).
+
+ ## Examples
+
+ iex> admin = %{role: %{permission_set_name: "admin"}}
+ iex> can?(admin, :create, Mv.Membership.Member)
+ true
+
+ iex> mitglied = %{role: %{permission_set_name: "own_data"}}
+ iex> can?(mitglied, :create, Mv.Membership.Member)
+ false
+ """
+ @spec can?(map() | nil, atom(), atom()) :: boolean()
+ def can?(nil, _action, _resource), do: false
+
+ def can?(user, action, resource) when is_atom(action) and is_atom(resource) do
+ with %{role: %{permission_set_name: ps_name}} <- user,
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom) do
+ resource_name = get_resource_name(resource)
+
+ Enum.any?(permissions.resources, fn perm ->
+ perm.resource == resource_name and
+ perm.action == action and
+ perm.granted
+ end)
+ else
+ _ -> false
+ end
+ end
+
+ @doc """
+ Checks if user has permission for an action on a specific record (struct).
+
+ Applies scope checking:
+ - :own - record.id == user.id
+ - :linked - record.user_id == user.id (or traverses relationships)
+ - :all - always true
+
+ ## Examples
+
+ iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}}
+ iex> member = %Member{id: "member-456", user_id: "user-123"}
+ iex> can?(user, :update, member)
+ true
+
+ iex> other_member = %Member{id: "member-789", user_id: "other-user"}
+ iex> can?(user, :update, other_member)
+ false
+ """
+ @spec can?(map() | nil, atom(), struct()) :: boolean()
+ def can?(nil, _action, _record), do: false
+
+ def can?(user, action, %resource{} = record) when is_atom(action) do
+ with %{role: %{permission_set_name: ps_name}} <- user,
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom) do
+ resource_name = get_resource_name(resource)
+
+ # Find matching permission
+ matching_perm = Enum.find(permissions.resources, fn perm ->
+ perm.resource == resource_name and
+ perm.action == action and
+ perm.granted
+ end)
+
+ case matching_perm do
+ nil -> false
+ perm -> check_scope(perm.scope, user, record, resource_name)
+ end
+ else
+ _ -> false
+ end
+ end
+
+ @doc """
+ Checks if user can access a specific page.
+
+ ## Examples
+
+ iex> admin = %{role: %{permission_set_name: "admin"}}
+ iex> can_access_page?(admin, "/admin/roles")
+ true
+
+ iex> mitglied = %{role: %{permission_set_name: "own_data"}}
+ iex> can_access_page?(mitglied, "/members")
+ false
+ """
+ @spec can_access_page?(map() | nil, String.t()) :: boolean()
+ def can_access_page?(nil, _page_path), do: false
+
+ def can_access_page?(user, page_path) do
+ with %{role: %{permission_set_name: ps_name}} <- user,
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom) do
+ page_matches?(permissions.pages, page_path)
+ else
+ _ -> false
+ end
+ end
+
+ # Check if scope allows access to record
+ defp check_scope(:all, _user, _record, _resource_name), do: true
+
+ defp check_scope(:own, user, record, _resource_name) do
+ record.id == user.id
+ end
+
+ defp check_scope(:linked, user, record, resource_name) do
+ case resource_name do
+ "Member" ->
+ # Direct relationship: member.user_id
+ Map.get(record, :user_id) == user.id
+
+ "Property" ->
+ # Need to traverse: property.member.user_id
+ # Note: In UI, property should have member preloaded
+ case Map.get(record, :member) do
+ %{user_id: member_user_id} -> member_user_id == user.id
+ _ -> false
+ end
+
+ _ ->
+ # Fallback: check user_id
+ Map.get(record, :user_id) == user.id
+ end
+ end
+
+ # Check if page path matches any allowed pattern
+ defp page_matches?(allowed_pages, requested_path) do
+ Enum.any?(allowed_pages, fn pattern ->
+ cond do
+ pattern == "*" -> true
+ pattern == requested_path -> true
+ String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
+ true -> false
+ end
+ end)
+ end
+
+ # Match dynamic route pattern
+ defp match_pattern?(pattern, path) do
+ pattern_segments = String.split(pattern, "/", trim: true)
+ path_segments = String.split(path, "/", trim: true)
+
+ if length(pattern_segments) == length(path_segments) do
+ Enum.zip(pattern_segments, path_segments)
+ |> Enum.all?(fn {pattern_seg, path_seg} ->
+ String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
+ end)
+ else
+ false
+ end
+ end
+
+ # Extract resource name from module
+ defp get_resource_name(resource) when is_atom(resource) do
+ resource |> Module.split() |> List.last()
+ end
+end
+```
+
+### Import in mv_web.ex
+
+Make helpers available to all LiveViews:
+
+```elixir
+defmodule MvWeb do
+ # ...
+
+ def html_helpers do
+ quote do
+ # ... existing helpers ...
+
+ # Authorization helpers
+ import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
+ end
+ end
+
+ # ...
+end
+```
+
+### UI Examples
+
+**Navbar with conditional links:**
+
+```heex
+
+
+```
+
+**Index page with conditional "New" button:**
+
+```heex
+
+
+
Members
+
+
+ <%= if can?(@current_user, :create, Mv.Membership.Member) do %>
+ <.link patch={~p"/members/new"} class="btn-primary">
+ New Member
+
+ <% end %>
+
+
+
+
+ <%= for member <- @members do %>
+
+
<%= member.name %>
+
+
+ <%= if can?(@current_user, :update, member) do %>
+ <.link patch={~p"/members/#{member.id}/edit"}>Edit
+ <% end %>
+
+
+ <%= if can?(@current_user, :destroy, member) do %>
+ <.button phx-click="delete" phx-value-id={member.id}>Delete
+ <% end %>
+
+
+
+ <%= if can?(@current_user, :update, @member) do %>
+ <.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary">
+ Edit Member
+
+ <% end %>
+
+```
+
+---
+
+## Special Cases
+
+### 1. Own Credentials Access
+
+**Requirement:** Every user can ALWAYS read and update their own credentials (email, password), regardless of their role.
+
+**Implementation:**
+
+Policy in `User` resource places this check BEFORE the general `HasPermission` check:
+
+```elixir
+policies do
+ # SPECIAL CASE: Takes precedence over role permissions
+ policy action_type([:read, :update]) do
+ description "Users can always read and update their own account"
+ authorize_if expr(id == ^actor(:id))
+ end
+
+ # GENERAL: For other operations (e.g., admin reading other users)
+ policy action_type([:read, :create, :update, :destroy]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+end
+```
+
+**Why this works:**
+- Ash evaluates policies top-to-bottom
+- First matching policy wins
+- Special case catches own-account access before checking permissions
+- Even a user with `own_data` (no admin permissions) can update their credentials
+
+### 2. Linked Member Email Editing
+
+**Requirement:** Only administrators can edit the email of a member that is linked to a user (has `user_id` set). This prevents breaking email synchronization.
+
+**Implementation:**
+
+Custom validation in `Member` resource:
+
+```elixir
+defmodule Mv.Membership.Member do
+ use Ash.Resource, ...
+
+ validations do
+ # Only run when email is being changed
+ validate changing(:email), on: :update do
+ validate &validate_linked_member_email_change/2
+ end
+ end
+
+ defp validate_linked_member_email_change(changeset, _context) do
+ member = changeset.data
+ actor = changeset.context[:actor]
+
+ # If member is not linked to user, allow change
+ if is_nil(member.user_id) do
+ :ok
+ else
+ # Member is linked - check if actor is admin
+ if has_admin_permission?(actor) do
+ :ok
+ else
+ {:error, "Only administrators can change email for members linked to user accounts"}
+ end
+ end
+ end
+
+ defp has_admin_permission?(nil), do: false
+
+ defp has_admin_permission?(actor) do
+ with %{role: %{permission_set_name: ps_name}} <- actor,
+ {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
+ permissions <- PermissionSets.get_permissions(ps_atom) do
+ # Check if actor has User.update permission with scope :all (admin privilege)
+ Enum.any?(permissions.resources, fn perm ->
+ perm.resource == "User" and
+ perm.action == :update and
+ perm.scope == :all and
+ perm.granted
+ end)
+ else
+ _ -> false
+ end
+ end
+end
+```
+
+**Why this is needed:**
+- Member email and User email are kept in sync
+- If a non-admin changes linked member email, it could create inconsistency
+- Validation runs AFTER policy check, so normal_user can update member
+- But validation blocks email field specifically if member is linked
+
+### 3. System Role Protection
+
+**Requirement:** The "Mitglied" role cannot be deleted because it's the default role for all users.
+
+**Implementation:**
+
+Flag + validation in `Role` resource:
+
+```elixir
+defmodule Mv.Authorization.Role do
+ use Ash.Resource, ...
+
+ attributes do
+ # ...
+ attribute :is_system_role, :boolean, default: false
+ end
+
+ validations do
+ validate action(:destroy) do
+ validate fn _changeset, %{data: role} ->
+ if role.is_system_role do
+ {:error, "Cannot delete system role. System roles are required for the application to function."}
+ else
+ :ok
+ end
+ end
+ end
+ end
+end
+```
+
+**Seeds set the flag:**
+
+```elixir
+%{
+ name: "Mitglied",
+ permission_set_name: "own_data",
+ is_system_role: true # <-- Protected!
+}
+```
+
+**UI hides delete button:**
+
+```heex
+<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %>
+ <.button phx-click="delete">Delete
+<% end %>
+```
+
+### 4. User Without Role (Edge Case)
+
+**Requirement:** Users without a role should be denied all access (except logout).
+
+**Implementation:**
+
+**Default Assignment:** Seeds assign "Mitglied" role to all existing users
+
+```elixir
+# In authorization_seeds.exs
+mitglied_role = Ash.get!(Role, name: "Mitglied")
+users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))
+
+Enum.each(users_without_role, fn user ->
+ Ash.update!(user, %{role_id: mitglied_role.id})
+end)
+```
+
+**Runtime Handling:** All authorization checks handle missing role gracefully
+
+```elixir
+# In HasPermission check
+def match?(actor, %{resource: resource, action: action}, _opts) do
+ with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
+ # ...
+ else
+ %{role: nil} ->
+ {:error, :no_role} # User has no role -> forbidden
+
+ _ ->
+ {:error, :no_permission}
+ end
+end
+```
+
+**Result:** User with no role sees empty UI, cannot access pages, gets forbidden on all actions.
+
+### 5. Invalid permission_set_name (Edge Case)
+
+**Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing.
+
+**Implementation:**
+
+**Prevention:** Validation on Role resource
+
+```elixir
+validations do
+ validate attribute(:permission_set_name) do
+ validate fn _changeset, value ->
+ if PermissionSets.valid_permission_set?(value) do
+ :ok
+ else
+ {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"}
+ end
+ end
+ end
+end
+```
+
+**Runtime Handling:** All lookups check validity
+
+```elixir
+# In PermissionSets module
+def permission_set_name_to_atom(name) when is_binary(name) do
+ atom = String.to_existing_atom(name)
+ if valid_permission_set?(atom) do
+ {:ok, atom}
+ else
+ {:error, :invalid_permission_set}
+ end
+rescue
+ ArgumentError -> {:error, :invalid_permission_set}
+end
+```
+
+**Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default).
+
+---
+
+## User-Member Linking
+
+### Requirement
+
+Users and Members are separate entities that can be linked. Special rules:
+- Only admins can link/unlink users and members
+- A user cannot link themselves to an existing member
+- A user CAN create a new member and be directly linked to it (self-service)
+
+### Approach: Separate Ash Actions
+
+We use **different Ash actions** to enforce different policies:
+
+1. **`create_member_for_self`** - User creates member and links to themselves
+2. **`create_member`** - Admin creates member for any user (or unlinked)
+3. **`link_member_to_user`** - Admin links existing member to user
+4. **`unlink_member_from_user`** - Admin removes user link
+5. **`update`** - Standard update (cannot change `user_id`)
+
+### Implementation
+
+```elixir
+defmodule Mv.Membership.Member do
+ use Ash.Resource, ...
+
+ actions do
+ # SELF-SERVICE: User creates member and links to self
+ create :create_member_for_self do
+ description "User creates a new member and links it to their own account"
+
+ accept [:name, :email, :address, ...] # All fields except user_id
+
+ # Automatically set user_id to actor
+ change set_attribute(:user_id, actor(:id))
+
+ # Prevent creating multiple members for same user (optional business rule)
+ validate fn changeset, _context ->
+ actor_id = get_change(changeset, :user_id)
+
+ case Ash.read(Member, filter: expr(user_id == ^actor_id)) do
+ {:ok, []} -> :ok # No existing member, allow
+ {:ok, [_member | _]} -> {:error, "You already have a member profile"}
+ {:error, _} -> :ok
+ end
+ end
+ end
+
+ # ADMIN: Create member with optional user link
+ create :create_member do
+ description "Admin creates a new member, optionally linked to a user"
+
+ accept [:name, :email, :address, ..., :user_id] # Admin can set user_id
+ end
+
+ # ADMIN: Link existing member to user
+ update :link_member_to_user do
+ description "Admin links an existing member to a user account"
+
+ accept [:user_id]
+
+ validate fn changeset, _context ->
+ member = changeset.data
+
+ # Cannot link if already linked
+ if is_nil(member.user_id) do
+ :ok
+ else
+ {:error, "Member is already linked to a user"}
+ end
+ end
+ end
+
+ # ADMIN: Remove user link from member
+ update :unlink_member_from_user do
+ description "Admin removes user link from member"
+
+ change set_attribute(:user_id, nil)
+ end
+
+ # STANDARD UPDATE: Cannot change user_id
+ update :update do
+ description "Update member data (cannot change user link)"
+
+ accept [:name, :email, :address, ...] # user_id NOT in accept list
+ end
+ end
+
+ policies do
+ # Self-service member creation
+ policy action(:create_member_for_self) do
+ description "Any authenticated user can create member for themselves"
+ authorize_if actor_present()
+ end
+
+ # Admin-only actions
+ policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do
+ description "Only admin can manage user-member links"
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+
+ # Standard actions (regular permission check)
+ policy action([:read, :update, :destroy]) do
+ authorize_if Mv.Authorization.Checks.HasPermission
+ end
+ end
+end
+```
+
+### UI Examples
+
+**User Self-Service:**
+
+```heex
+
+<%= if is_nil(@current_user.member_id) do %>
+ <.link navigate="/members/new_for_self">
+ Create My Member Profile
+
+<% end %>
+
+
+<.simple_form for={@form} phx-submit="create_for_self">
+ <.input field={@form[:name]} label="Name" />
+ <.input field={@form[:email]} label="Email" />
+ <.input field={@form[:address]} label="Address" />
+
+
+
+ <:actions>
+ <.button>Create My Profile
+
+
+```
+
+**Admin Interface:**
+
+```heex
+
+<%= if can?(@current_user, :link_member_to_user, @member) do %>
+ <%= if is_nil(@member.user_id) do %>
+
+ <.form for={@link_form} phx-submit="link_to_user">
+ <.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} />
+ <.button>Link to User
+
+ <% else %>
+
+ <.button phx-click="unlink_from_user" phx-value-id={@member.id}>
+ Unlink from User (<%= @member.user.email %>)
+
+ <% end %>
+<% end %>
+```
+
+### Why Separate Actions?
+
+✅ **Clear Intent:** Action name communicates what's happening
+✅ **Precise Policies:** Different policies for different operations
+✅ **Better UX:** Separate UI flows for self-service vs. admin
+✅ **Testable:** Each action can be tested independently
+✅ **Idiomatic Ash:** Uses Ash's action system as designed
+
+---
+
+## Future: Phase 2 - Field-Level Permissions
+
+**Status:** Not in MVP, planned for future enhancement
+
+**Goal:** Control which fields a user can read or write, beyond resource-level permissions.
+
+### Strategy
+
+**Extend PermissionSets module with `:fields` key:**
+
+```elixir
+def get_permissions(:read_only) do
+ %{
+ resources: [...],
+ pages: [...],
+ fields: [
+ # Vorstand can read all member fields except sensitive payment info
+ %{
+ resource: "Member",
+ action: :read,
+ fields: [:all],
+ excluded_fields: [:payment_method, :bank_account]
+ },
+
+ # Vorstand cannot write any member fields
+ %{
+ resource: "Member",
+ action: :update,
+ fields: [] # Empty = no fields writable
+ }
+ ]
+ }
+end
+```
+
+**Read Filtering via Ash Calculations:**
+
+```elixir
+defmodule Mv.Membership.Member do
+ calculations do
+ calculate :filtered_fields, :map do
+ calculate fn members, context ->
+ actor = context[:actor]
+
+ # Get allowed fields from PermissionSets
+ allowed_fields = get_allowed_read_fields(actor, "Member")
+
+ # Filter fields
+ Enum.map(members, fn member ->
+ Map.take(member, allowed_fields)
+ end)
+ end
+ end
+ end
+end
+```
+
+**Write Protection via Custom Validations:**
+
+```elixir
+validations do
+ validate on: :update do
+ validate fn changeset, context ->
+ actor = context[:actor]
+ changed_fields = Map.keys(changeset.attributes)
+
+ # Get allowed fields from PermissionSets
+ allowed_fields = get_allowed_write_fields(actor, "Member")
+
+ # Check if any forbidden field is being changed
+ forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields))
+
+ if Enum.empty?(forbidden) do
+ :ok
+ else
+ {:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"}
+ end
+ end
+ end
+end
+```
+
+**Benefits:**
+- ✅ No database schema changes
+- ✅ Still uses hardcoded PermissionSets
+- ✅ Granular control over sensitive fields
+- ✅ Clear error messages
+
+**Estimated Effort:** 2-3 weeks
+
+---
+
+## Future: Phase 3 - Database-Backed Permissions
+
+**Status:** Not in MVP, planned for future when runtime configuration is needed
+
+**Goal:** Move permission definitions from code to database for runtime configuration.
+
+### High-Level Design
+
+**New Tables:**
+
+```sql
+CREATE TABLE permission_sets (
+ id UUID PRIMARY KEY,
+ name VARCHAR(50) UNIQUE,
+ description TEXT,
+ is_system BOOLEAN
+);
+
+CREATE TABLE permission_set_resources (
+ id UUID PRIMARY KEY,
+ permission_set_id UUID REFERENCES permission_sets(id),
+ resource_name VARCHAR(100),
+ action VARCHAR(20),
+ scope VARCHAR(20),
+ granted BOOLEAN
+);
+
+CREATE TABLE permission_set_pages (
+ id UUID PRIMARY KEY,
+ permission_set_id UUID REFERENCES permission_sets(id),
+ page_pattern VARCHAR(255)
+);
+```
+
+**Migration Strategy:**
+
+1. Create new tables
+2. Seed from current `PermissionSets` module
+3. Create new `HasResourcePermission` check that queries DB
+4. Add ETS cache for performance
+5. Replace `HasPermission` with `HasResourcePermission` in policies
+6. Test thoroughly
+7. Deploy
+8. Eventually remove `PermissionSets` module
+
+**ETS Cache:**
+
+```elixir
+defmodule Mv.Authorization.PermissionCache do
+ def get_permissions(permission_set_id) do
+ case :ets.lookup(:permission_cache, permission_set_id) do
+ [{^permission_set_id, permissions}] ->
+ permissions
+
+ [] ->
+ permissions = load_from_db(permission_set_id)
+ :ets.insert(:permission_cache, {permission_set_id, permissions})
+ permissions
+ end
+ end
+
+ def invalidate(permission_set_id) do
+ :ets.delete(:permission_cache, permission_set_id)
+ end
+end
+```
+
+**Benefits:**
+- ✅ Runtime permission configuration
+- ✅ More flexible than hardcoded
+- ✅ Can add new permission sets without code changes
+
+**Trade-offs:**
+- ⚠️ More complex (DB queries, cache, invalidation)
+- ⚠️ Slightly slower (mitigated by cache)
+- ⚠️ More testing needed
+
+**Estimated Effort:** 3-4 weeks
+
+**Decision Point:** Migrate to Phase 3 only if:
+- Need to add permission sets frequently
+- Need per-tenant permission customization
+- MVP hardcoded approach is limiting business
+
+See [Migration Strategy](#migration-strategy) for detailed migration plan.
+
+---
+
+## Migration Strategy
+
+### Three-Phase Approach
+
+**Phase 1: MVP (2-3 weeks) - CURRENT**
+- Hardcoded PermissionSets module
+- `HasPermission` check reads from module
+- Role table with `permission_set_name` string
+- Zero DB queries for permission checks
+
+**Phase 2: Field-Level (2-3 weeks) - FUTURE**
+- Extend PermissionSets with `:fields` key
+- Ash Calculations for read filtering
+- Custom Validations for write protection
+- No database schema changes
+
+**Phase 3: Database-Backed (3-4 weeks) - FUTURE**
+- New tables: `permission_sets`, `permission_set_resources`, `permission_set_pages`
+- New `HasResourcePermission` check queries DB
+- ETS cache for performance
+- Runtime permission configuration
+
+### When to Migrate?
+
+**Stay with MVP if:**
+- 4 permission sets are sufficient
+- Permission changes are rare (quarterly or less)
+- Code deployments for permission changes are acceptable
+- Performance is critical (< 1μs checks)
+
+**Migrate to Phase 2 if:**
+- Need field-level granularity
+- Different roles need access to different fields
+- Still OK with hardcoded permissions
+
+**Migrate to Phase 3 if:**
+- Need frequent permission changes
+- Need per-tenant customization
+- Want non-technical users to configure permissions
+- OK with slightly more complex system
+
+### Migration from MVP to Phase 3
+
+**Step-by-step:**
+
+1. **Create DB Tables** (1 day)
+ - Run migrations for `permission_sets`, `permission_set_resources`, `permission_set_pages`
+ - Add indexes
+
+2. **Seed from PermissionSets Module** (1 day)
+ - Script that reads from `PermissionSets.get_permissions/1`
+ - Inserts into new tables
+ - Verify data integrity
+
+3. **Create HasResourcePermission Check** (2 days)
+ - New check that queries DB
+ - Same logic as `HasPermission` but different data source
+ - Comprehensive tests
+
+4. **Implement ETS Cache** (2 days)
+ - Cache module
+ - Cache invalidation on updates
+ - Performance tests
+
+5. **Update Policies** (3 days)
+ - Replace `HasPermission` with `HasResourcePermission` in all resources
+ - Test each resource thoroughly
+
+6. **Update UI Helpers** (1 day)
+ - Modify `MvWeb.Authorization` to query DB
+ - Use cache for performance
+
+7. **Update Page Plug** (1 day)
+ - Modify `CheckPagePermission` to query DB
+ - Use cache
+
+8. **Integration Testing** (3 days)
+ - Full user journey tests
+ - Performance testing
+ - Load testing
+
+9. **Deploy to Staging** (1 day)
+ - Feature flag approach
+ - Run both systems in parallel
+ - Compare results
+
+10. **Deploy to Production** (1 day)
+ - Gradual rollout
+ - Monitor performance
+ - Rollback plan ready
+
+11. **Cleanup** (1 day)
+ - Remove old `HasPermission` check
+ - Remove `PermissionSets` module
+ - Update documentation
+
+**Total:** ~3-4 weeks
+
+---
+
+## Security Considerations
+
+### Threat Model
+
+**Threats Addressed:**
+
+1. **Unauthorized Data Access:** Policies prevent users from accessing data outside their permissions
+2. **Privilege Escalation:** Role-based system prevents users from granting themselves higher privileges
+3. **UI Tampering:** Backend policies enforce authorization even if UI is bypassed
+4. **Session Hijacking:** Mitigation handled by existing authentication system (not in scope)
+
+**Threats NOT Addressed:**
+
+1. **SQL Injection:** Ash Framework handles query building securely
+2. **XSS:** Phoenix LiveView handles HTML escaping
+3. **CSRF:** Phoenix CSRF tokens (existing)
+
+### Defense in Depth
+
+**Three Layers of Authorization:**
+
+1. **Page Access Layer (Plug):**
+ - Blocks unauthorized page access
+ - Runs before LiveView mounts
+ - Fast fail for obvious violations
+
+2. **UI Layer (Authorization Helpers):**
+ - Hides buttons/links user can't use
+ - Prevents confusing "forbidden" errors
+ - Improves UX
+
+3. **Resource Layer (Ash Policies):**
+ - **Primary enforcement point**
+ - Cannot be bypassed
+ - Filters queries automatically
+
+**Even if attacker:**
+- Tampers with UI → Backend policies still enforce
+- Calls API directly → Policies apply
+- Modifies page JavaScript → Policies apply
+
+### Authorization Best Practices
+
+**DO:**
+- ✅ Always preload `:role` relationship for actor
+- ✅ Log authorization failures for debugging
+- ✅ Use explicit policies (no implicit allow)
+- ✅ Test policies with all role types
+- ✅ Test special cases (nil role, invalid permission_set_name)
+
+**DON'T:**
+- ❌ Trust UI-level checks alone
+- ❌ Skip policy checks for "admin"
+- ❌ Use `bypass` or `skip_authorization` in production
+- ❌ Expose raw permission logic in API responses
+
+### Audit Logging (Future)
+
+**Not in MVP, but planned:**
+
+```elixir
+defmodule Mv.Authorization.AuditLog do
+ def log_authorization_failure(actor, resource, action, reason) do
+ Ash.create!(AuditLog, %{
+ user_id: actor.id,
+ resource: inspect(resource),
+ action: action,
+ outcome: "forbidden",
+ reason: reason,
+ ip_address: get_ip_address(),
+ timestamp: DateTime.utc_now()
+ })
+ end
+end
+```
+
+**Benefits:**
+- Track suspicious authorization attempts
+- Compliance (GDPR requires access logs)
+- Debugging production issues
+
+---
+
+## Appendix
+
+### Glossary
+
+- **Permission Set:** Named collection of permissions (e.g., "admin", "read_only")
+- **Role:** Database entity linking users to permission sets
+- **Scope:** Range of records permission applies to (:own, :linked, :all)
+- **Actor:** Currently authenticated user in Ash context
+- **Policy:** Ash authorization rule on a resource
+- **System Role:** Role that cannot be deleted (is_system_role=true)
+- **Special Case:** Authorization rule that takes precedence over general permissions
+
+### Resource Name Mapping
+
+The `HasPermission` check extracts resource names via `Module.split() |> List.last()`:
+
+| Ash Module | Resource Name (String) |
+|------------|------------------------|
+| `Mv.Accounts.User` | "User" |
+| `Mv.Membership.Member` | "Member" |
+| `Mv.Membership.Property` | "Property" |
+| `Mv.Membership.PropertyType` | "PropertyType" |
+| `Mv.Authorization.Role` | "Role" |
+
+These strings must match exactly in `PermissionSets` module.
+
+### Permission Set Summary
+
+| Permission Set | Typical Roles | Key Characteristics |
+|----------------|---------------|---------------------|
+| **own_data** | Mitglied | Can only access own data and linked member |
+| **read_only** | Vorstand, Buchhaltung | Read all data, no modifications |
+| **normal_user** | Kassenwart | Create/Read/Update members (no delete), full CRUD on properties, no admin |
+| **admin** | Admin | Unrestricted access, wildcard pages |
+
+### Edge Case Reference
+
+| Edge Case | Behavior | Implementation |
+|-----------|----------|----------------|
+| User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully |
+| Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks |
+| System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` |
+| Linked member email | Admin-only edit | Custom validation in Member resource |
+| Own credentials | Always accessible | Special policy before general check |
+
+### Testing Checklist
+
+**For Each Resource:**
+- [ ] All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
+- [ ] All actions tested (read, create, update, destroy)
+- [ ] All scopes tested (own, linked, all)
+- [ ] Special cases tested
+- [ ] Edge cases tested (nil role, invalid permission_set_name)
+
+**For UI:**
+- [ ] Buttons/links show/hide correctly per role
+- [ ] Page access controlled per role
+- [ ] No broken links (all visible links are accessible)
+
+**Integration:**
+- [ ] One complete user journey per role
+- [ ] Cross-resource scenarios (e.g., Member -> Property)
+- [ ] Special cases in context (e.g., linked member email during full edit flow)
+
+### Useful Commands
+
+```bash
+# Run all authorization tests
+mix test test/mv/authorization
+
+# Run integration tests
+mix test test/integration
+
+# Run with coverage
+mix test --cover
+
+# Generate migrations
+mix ash.codegen
+
+# Run seeds
+mix run priv/repo/seeds/authorization_seeds.exs
+
+# Check permission for user in IEx
+iex> user = Mv.Accounts.get_user!("user-id")
+iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member)
+
+# Check page access in IEx
+iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
+```
+
+---
+
+**Document Version:** 2.0 (Clean Rewrite)
+**Last Updated:** 2025-01-13
+**Status:** Ready for Implementation
+
+**Changes from V1:**
+- Complete rewrite focused on MVP (hardcoded permissions)
+- Removed all database-backed permission details from MVP sections
+- Unified naming (HasPermission for MVP)
+- Added Role resource policies
+- Clarified resource-specific :linked scope
+- Moved Phase 2 and Phase 3 to clearly marked "Future" sections
+- Fixed Buchhaltung inconsistency (read_only everywhere)
+- Added comprehensive security section
+- Enhanced edge case documentation
+
+---
+
+**End of Architecture Document**
+
diff --git a/docs/roles-and-permissions-implementation-plan.md b/docs/roles-and-permissions-implementation-plan.md
new file mode 100644
index 0000000..0b173fa
--- /dev/null
+++ b/docs/roles-and-permissions-implementation-plan.md
@@ -0,0 +1,1653 @@
+# Roles and Permissions - Implementation Plan (MVP)
+
+**Version:** 2.0 (Clean Rewrite)
+**Date:** 2025-01-13
+**Status:** Ready for Implementation
+**Related Documents:**
+- [Overview](./roles-and-permissions-overview.md) - High-level concepts
+- [Architecture](./roles-and-permissions-architecture.md) - Technical specification
+
+---
+
+## Table of Contents
+
+- [Executive Summary](#executive-summary)
+- [MVP Scope](#mvp-scope)
+- [Implementation Strategy](#implementation-strategy)
+- [Issue Breakdown](#issue-breakdown)
+ - [Sprint 1: Foundation](#sprint-1-foundation-week-1)
+ - [Sprint 2: Policies](#sprint-2-policies-week-2)
+ - [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3)
+ - [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4)
+- [Dependencies & Parallelization](#dependencies--parallelization)
+- [Testing Strategy](#testing-strategy)
+- [Migration & Rollback](#migration--rollback)
+- [Risk Management](#risk-management)
+
+---
+
+## Executive Summary
+
+### Overview
+
+This document defines the implementation plan for the **MVP (Phase 1)** of the Roles and Permissions system using **hardcoded Permission Sets** in an Elixir module.
+
+**Key Characteristics:**
+- **15 issues total** (Issues #1-3, #6-17)
+- **2-3 weeks duration**
+- **180+ tests**
+- **Test-Driven Development (TDD)** throughout
+- **No database tables for permissions** - only `roles` table
+- **Zero performance concerns** - all permission checks are in-memory function calls
+
+### What's NOT in MVP
+
+**Deferred to Phase 3 (Future):**
+- Issue #4: `PermissionSetResource` database table
+- Issue #5: `PermissionSetPage` database table
+- Issue #18: ETS Permission Cache
+- Database-backed dynamic permissions
+
+### The Four Permission Sets
+
+Hardcoded in `Mv.Authorization.PermissionSets` module:
+
+1. **own_data** - User can only access their own data (default for "Mitglied")
+2. **read_only** - Read access to all members/properties (for "Vorstand", "Buchhaltung")
+3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart")
+4. **admin** - Unrestricted access including user/role management (for "Admin")
+
+### The Five Roles
+
+Stored in database `roles` table, each referencing a `permission_set_name`:
+
+1. **Mitglied** → "own_data" (is_system_role=true, default)
+2. **Vorstand** → "read_only"
+3. **Kassenwart** → "normal_user"
+4. **Buchhaltung** → "read_only"
+5. **Admin** → "admin"
+
+---
+
+## MVP Scope
+
+### What We're Building
+
+**Core Authorization System:**
+- ✅ Hardcoded PermissionSets module with 4 permission sets
+- ✅ Role database table and CRUD interface
+- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
+- ✅ Policies on all resources (Member, User, Property, PropertyType, Role)
+- ✅ Page-level permissions via Phoenix Plug
+- ✅ UI authorization helpers for conditional rendering
+- ✅ Special case: Member email validation for linked users
+- ✅ Seed data for 5 roles
+
+**Benefits of Hardcoded Approach:**
+- **Speed:** 2-3 weeks vs. 4-5 weeks for DB-backed
+- **Performance:** < 1 microsecond per check (pure function call)
+- **Simplicity:** No cache, no DB queries, easy to reason about
+- **Version Control:** All permission changes tracked in Git
+- **Testing:** Deterministic, no DB setup needed
+
+**Clear Migration Path to Phase 3:**
+- Architecture document defines exact DB schema for future
+- HasPermission check can be swapped for DB-querying version
+- Role->PermissionSet link remains unchanged
+
+---
+
+## Implementation Strategy
+
+### Test-Driven Development
+
+**Every issue follows TDD:**
+1. Write failing tests first
+2. Implement minimum code to pass tests
+3. Refactor if needed
+4. All tests must pass before moving on
+
+**Test Types:**
+- **Unit Tests:** Individual modules (PermissionSets, Policy checks, Helpers)
+- **Integration Tests:** Cross-resource authorization, special cases
+- **LiveView Tests:** UI rendering, page permissions
+- **E2E Tests:** Complete user journeys (one per role)
+
+### Incremental Rollout
+
+**Feature Flag Approach:**
+- Implement behind environment variable `ENABLE_RBAC`
+- Default: `false` (existing auth remains active)
+- Test thoroughly in staging
+- Flip flag in production after validation
+- Allows instant rollback if needed
+
+### Definition of Done (All Issues)
+
+- [ ] All acceptance criteria met
+- [ ] All tests written and passing
+- [ ] Code reviewed and approved
+- [ ] Documentation updated
+- [ ] No linter errors
+- [ ] Manual testing completed
+- [ ] Feature flag tested (on/off states)
+
+---
+
+## Issue Breakdown
+
+### Sprint 1: Foundation (Week 1)
+
+#### Issue #1: Create Authorization Domain and Role Resource
+
+**Size:** M (2 days)
+**Dependencies:** None
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Create the authorization domain in Ash with the `Role` resource. This establishes the foundation for all authorization logic.
+
+**Tasks:**
+
+1. Create `lib/mv/authorization/` directory
+2. Create `lib/mv/authorization/role.ex` Ash resource with:
+ - `id` (UUIDv7, primary key)
+ - `name` (String, unique, required) - e.g., "Vorstand", "Admin"
+ - `description` (String, optional)
+ - `permission_set_name` (String, required) - must be one of: "own_data", "read_only", "normal_user", "admin"
+ - `is_system_role` (Boolean, default false) - prevents deletion
+ - timestamps
+3. Add validation: `permission_set_name` must exist in `PermissionSets.all_permission_sets/0`
+4. Add `role_id` (UUID, nullable, foreign key) to `users` table
+5. Add `belongs_to :role` relationship in User resource
+6. Run `mix ash.codegen` to generate migrations
+7. Review and apply migrations
+
+**Acceptance Criteria:**
+
+- [ ] Role resource created with all fields
+- [ ] Migration applied successfully
+- [ ] User.role relationship works
+- [ ] Validation prevents invalid `permission_set_name`
+- [ ] `is_system_role` flag present
+
+**Test Strategy:**
+
+**Smoke Tests Only** (detailed behavior tests in later issues):
+
+- Role resource can be loaded via `Code.ensure_loaded?(Mv.Authorization.Role)`
+- Migration created valid table (manually verify with `psql`)
+- User resource can be loaded and has `:role` in `relationships()`
+
+**No extensive behavior tests** - those come in Issue #3 (Role CRUD).
+
+**Test File:** `test/mv/authorization/role_test.exs` (minimal smoke tests)
+
+---
+
+#### Issue #2: PermissionSets Elixir Module (Hardcoded Permissions)
+
+**Size:** M (2 days)
+**Dependencies:** None
+**Can work in parallel:** Yes (parallel with #1)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Create the core `PermissionSets` module that defines all four permission sets with their resource and page permissions. This is the heart of the MVP's authorization logic.
+
+**Tasks:**
+
+1. Create `lib/mv/authorization/permission_sets.ex`
+2. Define module with `@moduledoc` explaining the 4 permission sets
+3. Define types:
+ ```elixir
+ @type scope :: :own | :linked | :all
+ @type action :: :read | :create | :update | :destroy
+ @type resource_permission :: %{
+ resource: String.t(),
+ action: action(),
+ scope: scope(),
+ granted: boolean()
+ }
+ @type permission_set :: %{
+ resources: [resource_permission()],
+ pages: [String.t()]
+ }
+ ```
+4. Implement `get_permissions/1` for each of the 4 permission sets
+5. Implement `all_permission_sets/0` returning `[:own_data, :read_only, :normal_user, :admin]`
+6. Implement `valid_permission_set?/1` checking if name is in the list
+7. Implement `permission_set_name_to_atom/1` with error handling
+8. Add comprehensive `@doc` examples for each function
+
+**Permission Set Details:**
+
+**1. own_data (Mitglied):**
+- Resources:
+ - User: read/update :own
+ - Member: read/update :linked
+ - Property: read/update :linked
+ - PropertyType: read :all
+- Pages: `["/", "/profile", "/members/:id"]`
+
+**2. read_only (Vorstand, Buchhaltung):**
+- Resources:
+ - User: read :own, update :own
+ - Member: read :all
+ - Property: read :all
+ - PropertyType: read :all
+- Pages: `["/", "/members", "/members/:id", "/properties"]`
+
+**3. normal_user (Kassenwart):**
+- Resources:
+ - User: read/update :own
+ - Member: read/create/update :all (no destroy for safety)
+ - Property: read/create/update/destroy :all
+ - PropertyType: read :all
+- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/properties", "/properties/new", "/properties/:id/edit"]`
+
+**4. admin:**
+- Resources:
+ - User: read/update/destroy :all
+ - Member: read/create/update/destroy :all
+ - Property: read/create/update/destroy :all
+ - PropertyType: read/create/update/destroy :all
+ - Role: read/create/update/destroy :all
+- Pages: `["*"]` (wildcard = all pages)
+
+**Acceptance Criteria:**
+
+- [ ] Module created with all 4 permission sets
+- [ ] `get_permissions/1` returns correct structure for each set
+- [ ] `valid_permission_set?/1` works for atoms and strings
+- [ ] `permission_set_name_to_atom/1` handles errors gracefully
+- [ ] All functions have `@doc` and `@spec`
+- [ ] Code is readable and well-commented
+
+**Test Strategy (TDD):**
+
+**Structure Tests:**
+- `get_permissions(:own_data)` returns map with `:resources` and `:pages` keys
+- Each permission set returns list of resource permissions
+- Each resource permission has required keys: `:resource`, `:action`, `:scope`, `:granted`
+- Pages lists are non-empty (except potentially for restricted roles)
+
+**Permission Content Tests:**
+- `:own_data` allows User read/update with scope :own
+- `:own_data` allows Member/Property read/update with scope :linked
+- `:read_only` allows Member/Property read with scope :all
+- `:read_only` does NOT allow Member/Property create/update/destroy
+- `:normal_user` allows Member/Property full CRUD with scope :all
+- `:admin` allows everything with scope :all
+- `:admin` has wildcard page permission "*"
+
+**Validation Tests:**
+- `valid_permission_set?("own_data")` returns true
+- `valid_permission_set?(:admin)` returns true
+- `valid_permission_set?("invalid")` returns false
+- `permission_set_name_to_atom("own_data")` returns `{:ok, :own_data}`
+- `permission_set_name_to_atom("invalid")` returns `{:error, :invalid_permission_set}`
+
+**Edge Cases:**
+- All 4 sets defined in `all_permission_sets/0`
+- Function doesn't crash on nil input (returns false/error tuple)
+
+**Test File:** `test/mv/authorization/permission_sets_test.exs`
+
+---
+
+#### Issue #3: Role CRUD LiveViews
+
+**Size:** M (3 days)
+**Dependencies:** #1 (Role resource)
+**Assignable to:** Backend Developer + Frontend Developer
+
+**Description:**
+
+Create LiveView interface for administrators to manage roles. Only admins should be able to access this.
+
+**Tasks:**
+
+1. Create `lib/mv_web/live/role_live/` directory
+2. Implement `index.ex` - List all roles
+3. Implement `show.ex` - View role details
+4. Implement `form.ex` - Create/Edit role form component
+5. Add routes in `router.ex` under `/admin` scope
+6. Create table component showing: name, description, permission_set_name, is_system_role
+7. Add form validation for `permission_set_name` (dropdown with 4 options)
+8. Prevent deletion of system roles (UI + backend)
+9. Add flash messages for success/error
+10. Style with existing DaisyUI theme
+
+**Acceptance Criteria:**
+
+- [ ] Index page lists all roles
+- [ ] Show page displays role details
+- [ ] Form allows creating new roles
+- [ ] Form allows editing non-system roles
+- [ ] `permission_set_name` is dropdown (not free text)
+- [ ] Cannot delete system roles (grayed out button + backend check)
+- [ ] All CRUD operations work
+- [ ] Routes are under `/admin/roles`
+
+**Test Strategy (TDD):**
+
+**LiveView Mount Tests:**
+- Index page mounts successfully
+- Index page loads all roles from database
+- Show page mounts with valid role ID
+- Show page returns 404 for invalid role ID
+
+**CRUD Operation Tests:**
+- Create new role with valid data succeeds
+- Create new role with invalid `permission_set_name` shows error
+- Update role name succeeds
+- Update system role's `permission_set_name` succeeds
+- Delete non-system role succeeds
+- Delete system role fails with error message
+
+**UI Rendering Tests:**
+- Index page shows table with role names
+- System roles have badge/indicator
+- Delete button disabled for system roles
+- Form dropdown shows all 4 permission sets
+- Flash messages appear after actions
+
+**Test File:** `test/mv_web/live/role_live_test.exs`
+
+---
+
+### Sprint 2: Policies (Week 2)
+
+#### Issue #6: Custom Policy Check - HasPermission
+
+**Size:** L (3-4 days)
+**Dependencies:** #2 (PermissionSets), #3 (Role resource exists)
+**Assignable to:** Senior Backend Developer
+
+**Description:**
+
+Create the core custom Ash Policy Check that reads permissions from the `PermissionSets` module and applies them to Ash queries. This is the bridge between hardcoded permissions and Ash's authorization system.
+
+**Tasks:**
+
+1. Create `lib/mv/authorization/checks/has_permission.ex`
+2. Implement `use Ash.Policy.Check`
+3. Implement `describe/1` - returns human-readable description
+4. Implement `match?/3` - the core authorization logic:
+ - Extract `actor.role.permission_set_name`
+ - Convert to atom via `PermissionSets.permission_set_name_to_atom/1`
+ - Call `PermissionSets.get_permissions/1`
+ - Find matching permission for current resource + action
+ - Apply scope filter
+5. Implement `apply_scope/3` helper:
+ - `:all` → `:authorized` (no filter)
+ - `:own` → `{:filter, expr(id == ^actor.id)}`
+ - `:linked` → resource-specific logic:
+ - Member: `{:filter, expr(user_id == ^actor.id)}`
+ - Property: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!)
+6. Handle errors gracefully:
+ - No actor → `{:error, :no_actor}`
+ - No role → `{:error, :no_role}`
+ - Invalid permission_set_name → `{:error, :invalid_permission_set}`
+ - No matching permission → `{:error, :no_permission}`
+7. Add logging for authorization failures (debug level)
+8. Add comprehensive `@doc` with examples
+
+**Acceptance Criteria:**
+
+- [ ] Check module implements `Ash.Policy.Check` behavior
+- [ ] `match?/3` correctly evaluates permissions from PermissionSets
+- [ ] Scope filters work correctly (:all, :own, :linked)
+- [ ] `:linked` scope handles Member and Property differently
+- [ ] Errors are handled gracefully (no crashes)
+- [ ] Authorization failures are logged
+- [ ] Module is well-documented
+
+**Test Strategy (TDD):**
+
+**Permission Lookup Tests:**
+- Actor with :admin permission_set has permission for all resources/actions
+- Actor with :read_only permission_set has read permission for Member
+- Actor with :read_only permission_set does NOT have create permission for Member
+- Actor with :own_data permission_set has update permission for User with scope :own
+
+**Scope Application Tests - :all:**
+- Actor with scope :all can access any record
+- Query returns all records in database
+
+**Scope Application Tests - :own:**
+- Actor with scope :own can access record where record.id == actor.id
+- Actor with scope :own cannot access record where record.id != actor.id
+- Query filters to only actor's own record
+
+**Scope Application Tests - :linked:**
+- Actor with scope :linked can access Member where member.user_id == actor.id
+- Actor with scope :linked can access Property where property.member.user_id == actor.id (relationship traversal!)
+- Actor with scope :linked cannot access unlinked member
+- Query correctly filters based on user_id relationship
+
+**Error Handling Tests:**
+- `match?` with nil actor returns `{:error, :no_actor}`
+- `match?` with actor missing role returns `{:error, :no_role}`
+- `match?` with invalid permission_set_name returns `{:error, :invalid_permission_set}`
+- `match?` with no matching permission returns `{:error, :no_permission}`
+- No crashes on edge cases
+
+**Logging Tests:**
+- Authorization failure logs at debug level
+- Log includes actor ID, resource, action, reason
+
+**Test Files:**
+- `test/mv/authorization/checks/has_permission_test.exs`
+
+---
+
+#### Issue #7: Member Resource Policies
+
+**Size:** M (2 days)
+**Dependencies:** #6 (HasPermission check)
+**Can work in parallel:** Yes (parallel with #8, #9, #10)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Add authorization policies to the Member resource using the new `HasPermission` check.
+
+**Tasks:**
+
+1. Open `lib/mv/membership/member.ex`
+2. Add `policies` block at top of resource (before actions)
+3. Configure policy to `Mv.Authorization.Checks.HasPermission`
+4. Add policy for each action:
+ - `:read` → check HasPermission for :read
+ - `:create` → check HasPermission for :create
+ - `:update` → check HasPermission for :update
+ - `:destroy` → check HasPermission for :destroy
+5. Add special policy: Allow user to read/update their linked member (before general policy)
+ ```elixir
+ policy action_type(:read) do
+ authorize_if expr(user_id == ^actor(:id))
+ end
+ ```
+6. Ensure policies load actor with `:role` relationship preloaded
+7. Test policies with different actors
+
+**Policy Order (Critical!):**
+1. Allow user to access their own linked member (most specific)
+2. Check HasPermission (general authorization)
+3. Default: Forbid
+
+**Acceptance Criteria:**
+
+- [ ] Policies block added to Member resource
+- [ ] All CRUD actions protected by HasPermission
+- [ ] Special case: User can always access linked member
+- [ ] Policy order is correct (specific before general)
+- [ ] Actor preloads :role relationship
+- [ ] All policies tested
+
+**Test Strategy (TDD):**
+
+**Policy Tests for :own_data (Mitglied):**
+- User can read their linked member (user_id matches)
+- User can update their linked member
+- User cannot read unlinked member (returns empty list or forbidden)
+- User cannot create member
+- Verify scope :linked works
+
+**Policy Tests for :read_only (Vorstand):**
+- User can read all members (returns all records)
+- User cannot create member (returns Forbidden)
+- User cannot update any member (returns Forbidden)
+- User cannot destroy any member (returns Forbidden)
+
+**Policy Tests for :normal_user (Kassenwart):**
+- User can read all members
+- User can create new member
+- User can update any member
+- User cannot destroy member (not in permission set)
+
+**Policy Tests for :admin:**
+- User can perform all CRUD operations on any member
+- No restrictions
+
+**Test File:** `test/mv/membership/member_policies_test.exs`
+
+---
+
+#### Issue #8: User Resource Policies
+
+**Size:** M (2 days)
+**Dependencies:** #6 (HasPermission check)
+**Can work in parallel:** Yes (parallel with #7, #9, #10)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Add authorization policies to the User resource. Special case: Users can always read/update their own credentials.
+
+**Tasks:**
+
+1. Open `lib/mv/accounts/user.ex`
+2. Add `policies` block
+3. Add special policy: Allow user to always access their own account (before general policy)
+ ```elixir
+ policy action_type([:read, :update]) do
+ authorize_if expr(id == ^actor(:id))
+ end
+ ```
+4. Add general policy: Check HasPermission for all actions
+5. Ensure :destroy is admin-only (via HasPermission)
+6. Preload :role relationship for actor
+
+**Policy Order:**
+1. Allow user to read/update own account (id == actor.id)
+2. Check HasPermission (for admin operations)
+3. Default: Forbid
+
+**Acceptance Criteria:**
+
+- [ ] User can always read/update own credentials
+- [ ] Only admin can read/update other users
+- [ ] Only admin can destroy users
+- [ ] Policy order is correct
+- [ ] Actor preloads :role relationship
+
+**Test Strategy (TDD):**
+
+**Own Data Tests (All Roles):**
+- User with :own_data can read own user record
+- User with :own_data can update own email/password
+- User with :own_data cannot read other users
+- User with :read_only can read own data
+- User with :normal_user can read own data
+- Verify special policy takes precedence
+
+**Admin Tests:**
+- Admin can read all users
+- Admin can update any user's credentials
+- Admin can destroy users
+- Admin has unrestricted access
+
+**Forbidden Tests:**
+- Non-admin cannot read other users
+- Non-admin cannot update other users
+- Non-admin cannot destroy users
+
+**Test File:** `test/mv/accounts/user_policies_test.exs`
+
+---
+
+#### Issue #9: Property Resource Policies
+
+**Size:** M (2 days)
+**Dependencies:** #6 (HasPermission check)
+**Can work in parallel:** Yes (parallel with #7, #8, #10)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Add authorization policies to the Property resource. Properties are linked to members, which are linked to users.
+
+**Tasks:**
+
+1. Open `lib/mv/membership/property.ex`
+2. Add `policies` block
+3. Add special policy: Allow user to read/update properties of their linked member
+ ```elixir
+ policy action_type([:read, :update]) do
+ authorize_if expr(member.user_id == ^actor(:id))
+ end
+ ```
+4. Add general policy: Check HasPermission
+5. Ensure Property preloads :member relationship for scope checks
+6. Preload :role relationship for actor
+
+**Policy Order:**
+1. Allow user to read/update properties of linked member
+2. Check HasPermission
+3. Default: Forbid
+
+**Acceptance Criteria:**
+
+- [ ] User can access properties of their linked member
+- [ ] Policy traverses Member -> User relationship correctly
+- [ ] HasPermission check works for other scopes
+- [ ] Actor preloads :role relationship
+
+**Test Strategy (TDD):**
+
+**Linked Properties Tests (:own_data):**
+- User can read properties of their linked member
+- User can update properties of their linked member
+- User cannot read properties of unlinked members
+- Verify relationship traversal works (property.member.user_id)
+
+**Read-Only Tests:**
+- User with :read_only can read all properties
+- User with :read_only cannot create/update properties
+
+**Normal User Tests:**
+- User with :normal_user can CRUD properties
+
+**Admin Tests:**
+- Admin can perform all operations
+
+**Test File:** `test/mv/membership/property_policies_test.exs`
+
+---
+
+#### Issue #10: PropertyType Resource Policies
+
+**Size:** S (1 day)
+**Dependencies:** #6 (HasPermission check)
+**Can work in parallel:** Yes (parallel with #7, #8, #9)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Add authorization policies to the PropertyType resource. PropertyTypes are admin-managed, but readable by all.
+
+**Tasks:**
+
+1. Open `lib/mv/membership/property_type.ex`
+2. Add `policies` block
+3. Add read policy: All authenticated users can read (scope :all)
+4. Add write policies: Only admin can create/update/destroy
+5. Use HasPermission check
+
+**Acceptance Criteria:**
+
+- [ ] All users can read property types
+- [ ] Only admin can create/update/destroy property types
+- [ ] Policies tested
+
+**Test Strategy (TDD):**
+
+**Read Access (All Roles):**
+- User with :own_data can read all property types
+- User with :read_only can read all property types
+- User with :normal_user can read all property types
+- User with :admin can read all property types
+
+**Write Access (Admin Only):**
+- Non-admin cannot create property type (Forbidden)
+- Non-admin cannot update property type (Forbidden)
+- Non-admin cannot destroy property type (Forbidden)
+- Admin can create property type
+- Admin can update property type
+- Admin can destroy property type
+
+**Test File:** `test/mv/membership/property_type_policies_test.exs`
+
+---
+
+#### Issue #11: Page Permission Router Plug
+
+**Size:** S (1 day)
+**Dependencies:** #2 (PermissionSets), #6 (HasPermission)
+**Can work in parallel:** Yes (after #2 and #6)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Create a Phoenix plug that checks if the current user has permission to access the requested page/route. This runs before LiveView mounts.
+
+**Tasks:**
+
+1. Create `lib/mv_web/plugs/check_page_permission.ex`
+2. Implement `init/1` and `call/2`
+3. Extract page path from `conn.private[:phoenix_route]` (route template like "/members/:id")
+4. Get user from `conn.assigns[:current_user]`
+5. Get user's role and permission_set_name
+6. Call `PermissionSets.get_permissions/1` to get allowed pages list
+7. Match requested path against allowed patterns:
+ - Exact match: "/members" == "/members"
+ - Dynamic match: "/members/:id" matches "/members/123"
+ - Wildcard: "*" matches everything (admin)
+8. If unauthorized: redirect to "/" with flash error "You don't have permission to access this page."
+9. If authorized: continue (conn not halted)
+10. Add plug to router pipelines (`:browser`, `:require_authenticated_user`)
+
+**Acceptance Criteria:**
+
+- [ ] Plug checks page permissions from PermissionSets
+- [ ] Static routes work ("/members")
+- [ ] Dynamic routes work ("/members/:id" matches "/members/123")
+- [ ] Wildcard works for admin ("*")
+- [ ] Unauthorized users redirected with flash message
+- [ ] Plug added to appropriate router pipelines
+
+**Test Strategy (TDD):**
+
+**Static Route Tests:**
+- User with permission for "/members" can access (conn not halted)
+- User without permission for "/members" is denied (conn halted, redirected to "/")
+- Flash error message present after denial
+
+**Dynamic Route Tests:**
+- User with "/members/:id" permission can access "/members/123"
+- User with "/members/:id/edit" permission can access "/members/456/edit"
+- User with only "/members/:id" cannot access "/members/123/edit"
+- Pattern matching works correctly
+
+**Wildcard Tests:**
+- Admin with "*" permission can access any page
+- Wildcard overrides all other checks
+
+**Unauthenticated User Tests:**
+- Nil current_user is redirected to login
+- Login redirect preserves attempted path (optional feature)
+
+**Error Handling Tests:**
+- User with invalid permission_set_name is denied
+- User with no role is denied
+- Error is logged but user sees generic message
+
+**Test File:** `test/mv_web/plugs/check_page_permission_test.exs`
+
+---
+
+### Sprint 3: Special Cases & Seeds (Week 3)
+
+#### Issue #12: Member Email Validation for Linked Members
+
+**Size:** M (2 days)
+**Dependencies:** #7 (Member policies), #8 (User policies)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Implement special validation: Only admins can edit a member's email if that member is linked to a user. This prevents breaking email synchronization.
+
+**Tasks:**
+
+1. Open `lib/mv/membership/member.ex`
+2. Add custom validation in `validations` block:
+ ```elixir
+ validate changing(:email), on: :update do
+ validate &validate_email_change_permission/2
+ end
+ ```
+3. Implement `validate_email_change_permission/2`:
+ - Check if member has `user_id` (is linked)
+ - If linked: Check if actor has User.update permission with scope :all (admin)
+ - If not admin: Return error "Only administrators can change email for members linked to users"
+ - If not linked: Allow change
+4. Use `PermissionSets.get_permissions/1` to check admin status
+5. Add tests for all cases
+
+**Acceptance Criteria:**
+
+- [ ] Non-admin can edit email of unlinked member
+- [ ] Non-admin cannot edit email of linked member
+- [ ] Admin can edit email of linked member
+- [ ] Validation only runs when email changes
+- [ ] Error message is clear and helpful
+
+**Test Strategy (TDD):**
+
+**Unlinked Member Tests:**
+- User with :normal_user can update email of unlinked member
+- User with :read_only cannot update email (caught by policy, not validation)
+- Validation doesn't block if member.user_id is nil
+
+**Linked Member Tests:**
+- User with :normal_user cannot update email of linked member (validation error)
+- Error message mentions "administrators" and "linked to users"
+- User with :admin can update email of linked member (validation passes)
+
+**No-Op Tests:**
+- Validation doesn't run if email didn't change
+- Updating other fields (name, address) works normally
+
+**Test File:** `test/mv/membership/member_email_validation_test.exs`
+
+---
+
+#### Issue #13: Seed Data - Roles and Default Assignment
+
+**Size:** S (1 day)
+**Dependencies:** #2 (PermissionSets), #3 (Role resource)
+**Can work in parallel:** Yes (parallel with #12 after #2 and #3 complete)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Create seed data for 5 roles and assign default "Mitglied" role to existing users. Optionally designate one admin via environment variable.
+
+**Tasks:**
+
+1. Create `priv/repo/seeds/authorization_seeds.exs`
+2. Seed 5 roles using `Ash.Seed.seed!/2` or create actions:
+ - **Mitglied:** name="Mitglied", description="Default member role", permission_set_name="own_data", is_system_role=true
+ - **Vorstand:** name="Vorstand", description="Board member with read access", permission_set_name="read_only", is_system_role=false
+ - **Kassenwart:** name="Kassenwart", description="Treasurer with full member management", permission_set_name="normal_user", is_system_role=false
+ - **Buchhaltung:** name="Buchhaltung", description="Accounting with read access", permission_set_name="read_only", is_system_role=false
+ - **Admin:** name="Admin", description="Administrator with full access", permission_set_name="admin", is_system_role=false
+3. Make idempotent: Use upsert logic (get by name, update if exists, create if not)
+4. Assign "Mitglied" role to all users without role_id:
+ ```elixir
+ mitglied_role = Ash.get!(Role, name: "Mitglied")
+ users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))
+ Enum.each(users_without_role, fn user ->
+ Ash.update!(user, %{role_id: mitglied_role.id})
+ end)
+ ```
+5. (Optional) Check for `ADMIN_EMAIL` env var, assign Admin role to that user
+6. Add error handling with clear error messages
+7. Add `IO.puts` statements to show progress
+
+**Acceptance Criteria:**
+
+- [ ] All 5 roles created with correct permission_set_name
+- [ ] "Mitglied" has is_system_role=true
+- [ ] Existing users without role get "Mitglied" role
+- [ ] Optional: ADMIN_EMAIL user gets Admin role
+- [ ] Seeds are idempotent (can run multiple times)
+- [ ] Error messages are clear
+- [ ] Progress is logged to console
+
+**Test Strategy (TDD):**
+
+**Role Creation Tests:**
+- After running seeds, 5 roles exist
+- Each role has correct permission_set_name:
+ - Mitglied → "own_data"
+ - Vorstand → "read_only"
+ - Kassenwart → "normal_user"
+ - Buchhaltung → "read_only"
+ - Admin → "admin"
+- "Mitglied" role has is_system_role=true
+- Other roles have is_system_role=false
+- All permission_set_names are valid (exist in PermissionSets.all_permission_sets/0)
+
+**User Assignment Tests:**
+- Users without role_id are assigned "Mitglied" role
+- Users who already have role_id are not changed
+- Count of users with "Mitglied" role increases by number of previously unassigned users
+
+**Idempotency Tests:**
+- Running seeds twice doesn't create duplicate roles
+- Each role name appears exactly once
+- Running seeds twice doesn't reassign users who already have roles
+
+**Optional Admin Tests:**
+- If ADMIN_EMAIL set, user with that email gets Admin role
+- If ADMIN_EMAIL not set, no error occurs
+- If email doesn't exist, error is logged but seeds continue
+
+**Error Handling Tests:**
+- Seeds fail gracefully if invalid permission_set_name provided
+- Error message indicates which permission_set_name is invalid
+
+**Test File:** `test/seeds/authorization_seeds_test.exs`
+
+---
+
+### Sprint 4: UI & Integration (Week 4)
+
+#### Issue #14: UI Authorization Helper Module
+
+**Size:** M (2-3 days)
+**Dependencies:** #2 (PermissionSets), #6 (HasPermission), #13 (Seeds - for testing)
+**Assignable to:** Backend Developer + Frontend Developer
+
+**Description:**
+
+Create helper functions for UI-level authorization checks. These will be used in LiveView templates to conditionally render buttons, links, and sections based on user permissions.
+
+**Tasks:**
+
+1. Create `lib/mv_web/authorization.ex`
+2. Implement `can?/3` for resource-level checks:
+ ```elixir
+ def can?(user, action, resource) when is_atom(resource)
+ # Returns true if user has permission for action on resource
+ # e.g., can?(current_user, :create, Mv.Membership.Member)
+ ```
+3. Implement `can?/3` for record-level checks:
+ ```elixir
+ def can?(user, action, %resource{} = record)
+ # Returns true if user has permission for action on specific record
+ # Applies scope checking (own, linked, all)
+ # e.g., can?(current_user, :update, member)
+ ```
+4. Implement `can_access_page?/2`:
+ ```elixir
+ def can_access_page?(user, page_path)
+ # Returns true if user's permission set includes page
+ # e.g., can_access_page?(current_user, "/members/new")
+ ```
+5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission)
+6. All functions handle nil user gracefully (return false)
+7. Implement resource-specific scope checking (Member vs Property for :linked)
+8. Add comprehensive `@doc` with template examples
+9. Import helper in `mv_web.ex` `html_helpers` section
+
+**Acceptance Criteria:**
+
+- [ ] `can?/3` works for resource atoms
+- [ ] `can?/3` works for record structs with scope checking
+- [ ] `can_access_page?/2` matches page patterns correctly
+- [ ] Nil user always returns false
+- [ ] Invalid permission_set_name returns false (not crash)
+- [ ] Helper imported in `mv_web.ex`
+- [ ] Comprehensive documentation with examples
+
+**Test Strategy (TDD):**
+
+**can?/3 with Resource Atom:**
+- Returns true when user has permission for resource+action
+- Admin can create Member (returns true)
+- Read-only cannot create Member (returns false)
+- Nil user returns false
+
+**can?/3 with Record Struct - Scope :all:**
+- Admin can update any member (returns true for any record)
+- Normal user can update any member (scope :all)
+
+**can?/3 with Record Struct - Scope :own:**
+- User can update own User record (record.id == user.id)
+- User cannot update other User record (record.id != user.id)
+
+**can?/3 with Record Struct - Scope :linked:**
+- User can update linked Member (member.user_id == user.id)
+- User cannot update unlinked Member
+- User can update Property of linked Member (property.member.user_id == user.id)
+- User cannot update Property of unlinked Member
+- Scope checking is resource-specific (Member vs Property)
+
+**can_access_page?/2:**
+- User with page in list can access (returns true)
+- User without page in list cannot access (returns false)
+- Dynamic routes match correctly ("/members/:id" matches "/members/123")
+- Admin wildcard "*" matches any page
+- Nil user returns false
+
+**Error Handling:**
+- User without role returns false
+- User with invalid permission_set_name returns false (no crash)
+- Handles missing fields gracefully
+
+**Test File:** `test/mv_web/authorization_test.exs`
+
+---
+
+#### Issue #15: Admin UI for Role Management
+
+**Size:** M (2 days)
+**Dependencies:** #14 (UI Authorization Helper)
+**Assignable to:** Frontend Developer
+
+**Description:**
+
+Update Role management LiveViews to use authorization helpers for conditional rendering. Add UI polish.
+
+**Tasks:**
+
+1. Open `lib/mv_web/live/role_live/index.ex`
+2. Add authorization checks for "New Role" button:
+ ```heex
+ <%= if can?(@current_user, :create, Mv.Authorization.Role) do %>
+ <.link patch={~p"/admin/roles/new"}>New Role
+ <% end %>
+ ```
+3. Add authorization checks for "Edit" and "Delete" buttons in table
+4. Gray out/hide "Delete" for system roles
+5. Update `show.ex` to hide edit button if user can't update
+6. Add role badge/pill for system roles
+7. Add permission_set_name badge with color coding:
+ - own_data → gray
+ - read_only → blue
+ - normal_user → green
+ - admin → red
+8. Test UI with different user roles
+
+**Acceptance Criteria:**
+
+- [ ] Only admin sees "New Role" button
+- [ ] Only admin sees "Edit" and "Delete" buttons
+- [ ] System roles have visual indicator
+- [ ] Delete button hidden/disabled for system roles
+- [ ] Permission set badges are color-coded
+- [ ] UI tested with all role types
+
+**Test Strategy (TDD):**
+
+**Admin View:**
+- Admin sees "New Role" button
+- Admin sees "Edit" buttons for all roles
+- Admin sees "Delete" buttons for non-system roles
+- Admin does not see "Delete" button for system roles
+
+**Non-Admin View:**
+- Non-admin does not see "New Role" button (redirected by page permission plug anyway)
+- Non-admin cannot access /admin/roles (caught by plug)
+
+**Visual Tests:**
+- System roles have badge
+- Permission set names are color-coded
+- UI renders correctly
+
+**Test File:** `test/mv_web/live/role_live_authorization_test.exs`
+
+---
+
+#### Issue #16: Apply UI Authorization to Existing LiveViews
+
+**Size:** L (3 days)
+**Dependencies:** #14 (UI Authorization Helper)
+**Can work in parallel:** Yes (parallel with #15)
+**Assignable to:** Frontend Developer
+
+**Description:**
+
+Update all existing LiveViews (Member, User, Property, PropertyType) to use authorization helpers for conditional rendering.
+
+**Tasks:**
+
+1. **Member LiveViews:**
+ - Index: Hide "New Member" if can't create
+ - Index: Hide "Edit" and "Delete" buttons per record if can't update/destroy
+ - Show: Hide "Edit" button if can't update record
+ - Form: Should not be accessible (caught by page permission plug)
+
+2. **User LiveViews:**
+ - Index: Only show if user is admin
+ - Show: Only show other users if admin, always show own profile
+ - Edit: Only allow editing own profile or admin editing anyone
+
+3. **Property LiveViews:**
+ - Similar to Member (hide create/edit/delete based on permissions)
+
+4. **PropertyType LiveViews:**
+ - All users can view
+ - Only admin can create/edit/delete
+
+5. **Navbar:**
+ - Only show "Admin" dropdown if user has admin permission set
+ - Only show "Roles" link if can access /admin/roles
+ - Only show "Members" link if can access /members
+ - Always show "Profile" link
+
+6. Test all views with all 5 role types
+
+**Acceptance Criteria:**
+
+- [ ] All LiveViews use `can?/3` for conditional rendering
+- [ ] Buttons/links hidden when user lacks permission
+- [ ] Navbar shows appropriate links per role
+- [ ] Tested with all 5 roles (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
+- [ ] UI is clean (no awkward empty spaces from hidden buttons)
+
+**Test Strategy (TDD):**
+
+**Member Index - Mitglied (own_data):**
+- Does not see "New Member" button
+- Does not see list of members (empty or filtered)
+- Can only see own linked member if navigated directly
+
+**Member Index - Vorstand (read_only):**
+- Sees full member list
+- Does not see "New Member" button
+- Does not see "Edit" or "Delete" buttons
+
+**Member Index - Kassenwart (normal_user):**
+- Sees full member list
+- Sees "New Member" button
+- Sees "Edit" button for all members
+- Does not see "Delete" button (not in permission set)
+
+**Member Index - Admin:**
+- Sees everything (New, Edit, Delete)
+
+**Navbar Tests (all roles):**
+- Mitglied: Sees only "Home" and "Profile"
+- Vorstand: Sees "Home", "Members" (read-only), "Profile"
+- Kassenwart: Sees "Home", "Members", "Properties", "Profile"
+- Buchhaltung: Sees "Home", "Members" (read-only), "Profile"
+- Admin: Sees "Home", "Members", "Properties", "Property Types", "Admin", "Profile"
+
+**Test Files:**
+- `test/mv_web/live/member_live_authorization_test.exs`
+- `test/mv_web/live/user_live_authorization_test.exs`
+- `test/mv_web/live/property_live_authorization_test.exs`
+- `test/mv_web/live/property_type_live_authorization_test.exs`
+- `test/mv_web/components/navbar_authorization_test.exs`
+
+---
+
+#### Issue #17: Integration Tests - Complete User Journeys
+
+**Size:** L (3 days)
+**Dependencies:** All above (full system must be functional)
+**Assignable to:** Backend Developer
+
+**Description:**
+
+Write comprehensive integration tests that follow complete user journeys for each role. These tests verify that policies, UI helpers, and page permissions all work together correctly.
+
+**Tasks:**
+
+1. Create test file for each role:
+ - `test/integration/mitglied_journey_test.exs`
+ - `test/integration/vorstand_journey_test.exs`
+ - `test/integration/kassenwart_journey_test.exs`
+ - `test/integration/buchhaltung_journey_test.exs`
+ - `test/integration/admin_journey_test.exs`
+
+2. Each test follows a complete user flow:
+ - Login as user with role
+ - Navigate to allowed pages
+ - Attempt to access forbidden pages
+ - Perform allowed actions
+ - Attempt forbidden actions
+ - Verify UI shows/hides appropriate elements
+
+3. Test cross-cutting concerns:
+ - Email synchronization (Member <-> User)
+ - User-Member linking (admin only)
+ - System role protection
+
+**Acceptance Criteria:**
+
+- [ ] One integration test per role (5 total)
+- [ ] Tests cover complete user journeys
+- [ ] Tests verify both backend (policies) and frontend (UI helpers)
+- [ ] Tests verify page permissions
+- [ ] Tests verify special cases (email, linking, system roles)
+- [ ] All tests pass
+
+**Test Strategy:**
+
+**Mitglied Journey:**
+1. Login as Mitglied user
+2. Can access home page and profile
+3. Cannot access /members (redirected)
+4. Cannot access /admin/roles (redirected)
+5. Can view own linked member via direct URL
+6. Can update own member data
+7. Cannot update unlinked member
+8. Can update own user credentials
+9. Cannot view other users
+
+**Vorstand Journey:**
+1. Login as Vorstand user
+2. Can access /members (reads all members)
+3. Cannot create member (no button in UI, backend forbids)
+4. Cannot edit member (no button in UI, backend forbids)
+5. Can access /members/:id (read-only view)
+6. Cannot access /members/:id/edit (page permission denies)
+7. Can update own credentials
+8. Cannot access /admin/roles
+
+**Kassenwart Journey:**
+1. Login as Kassenwart user
+2. Can access /members
+3. Can create new member
+4. Can edit any member (except email if linked - see special case)
+5. Cannot delete member
+6. Can manage properties
+7. Cannot manage property types (read-only)
+8. Cannot access /admin/roles
+
+**Buchhaltung Journey:**
+1. Login as Buchhaltung user
+2. Can access /members (read-only)
+3. Cannot create/edit members
+4. Can view properties (read-only)
+5. Same restrictions as Vorstand
+
+**Admin Journey:**
+1. Login as Admin user
+2. Can access all pages (wildcard permission)
+3. Can CRUD all resources
+4. Can edit member email even if linked
+5. Can manage roles
+6. Cannot delete system roles (backend prevents)
+7. Can link/unlink users and members
+8. Can edit any user's credentials
+
+**Special Cases Tests:**
+- Member email editing (admin vs non-admin for linked member)
+- System role deletion (always fails)
+- User without role (access denied everywhere)
+- User with invalid permission_set_name (access denied)
+
+**Test Files:**
+- `test/integration/mitglied_journey_test.exs`
+- `test/integration/vorstand_journey_test.exs`
+- `test/integration/kassenwart_journey_test.exs`
+- `test/integration/buchhaltung_journey_test.exs`
+- `test/integration/admin_journey_test.exs`
+- `test/integration/special_cases_test.exs`
+
+---
+
+## Dependencies & Parallelization
+
+### Dependency Graph
+
+```
+ ┌──────────────────┐
+ │ Issue #1 │
+ │ Auth Domain │
+ │ + Role Res │
+ └────────┬─────────┘
+ │
+ ┌────────────┴────────────┐
+ │ │
+ ┌───────▼────────┐ ┌───────▼────────┐
+ │ Issue #2 │ │ Issue #3 │
+ │ PermissionSets│ │ Role CRUD │
+ │ Module │ │ LiveViews │
+ └───────┬────────┘ └────────────────┘
+ │
+ │
+ └────────────┬────────────┘
+ │
+ ┌────────▼─────────┐
+ │ Issue #6 │
+ │ HasPermission │
+ │ Policy Check │
+ └────────┬─────────┘
+ │
+ ┌────────────────────┼─────────────────────┐
+ │ │ │
+ ┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
+ │ Issue #7 │ │ Issue #8 │ │ Issue #11 │
+ │ Member │ │ User │ │ Page Plug │
+ │ Policies │ │ Policies │ └──────┬──────┘
+ └────┬─────┘ └──────┬──────┘ │
+ │ │ │
+ ┌────▼─────┐ ┌──────▼──────┐ │
+ │ Issue #9 │ │ Issue #10 │ │
+ │ Property │ │ PropType │ │
+ │ Policies │ │ Policies │ │
+ └────┬─────┘ └──────┬──────┘ │
+ │ │ │
+ └────────────────────┴─────────────────────┘
+ │
+ ┌────────────┴────────────┐
+ │ │
+ ┌───────▼────────┐ ┌───────▼────────┐
+ │ Issue #12 │ │ Issue #13 │
+ │ Email Valid │ │ Seeds │
+ └───────┬────────┘ └───────┬────────┘
+ │ │
+ └────────────┬────────────┘
+ │
+ ┌────────▼─────────┐
+ │ Issue #14 │
+ │ UI Helper │
+ └────────┬─────────┘
+ │
+ ┌────────────┴────────────┐
+ │ │
+ ┌───────▼────────┐ ┌───────▼────────┐
+ │ Issue #15 │ │ Issue #16 │
+ │ Admin UI │ │ Apply UI Auth│
+ └───────┬────────┘ └───────┬────────┘
+ │ │
+ └────────────┬────────────┘
+ │
+ ┌────────▼─────────┐
+ │ Issue #17 │
+ │ Integration │
+ │ Tests │
+ └──────────────────┘
+```
+
+### Parallelization Opportunities
+
+**After Issue #1:**
+- Issues #2 and #3 can run in parallel
+
+**After Issue #6:**
+- Issues #7, #8, #9, #10, #11 can ALL run in parallel (5 issues!)
+- This is the main parallelization opportunity
+
+**After Issues #7-#11:**
+- Issues #12 and #13 can run in parallel
+
+**After Issue #14:**
+- Issues #15 and #16 can run in parallel
+
+### Sprint Breakdown
+
+| Sprint | Issues | Duration | Can Parallelize |
+|--------|--------|----------|-----------------|
+| Sprint 1 | #1, #2, #3 | Week 1 | #2 and #3 after #1 |
+| Sprint 2 | #6, #7, #8, #9, #10, #11 | Week 2 | #7-#11 after #6 (5 parallel!) |
+| Sprint 3 | #12, #13 | Week 3 | Yes (2 parallel) |
+| Sprint 4 | #14, #15, #16, #17 | Week 4 | #15 & #16 after #14 |
+
+---
+
+## Testing Strategy
+
+### Test-Driven Development Process
+
+**For Every Issue:**
+1. Read acceptance criteria
+2. Write failing tests covering all criteria
+3. Verify tests fail (red)
+4. Implement minimum code to pass
+5. Verify tests pass (green)
+6. Refactor if needed
+7. All tests still pass
+
+### Test Coverage Goals
+
+**Total Estimated Tests: 180+**
+
+| Test Type | Count | Coverage |
+|-----------|-------|----------|
+| Unit Tests | ~80 | PermissionSets module, Policy checks, Scope logic, UI helpers |
+| Integration Tests | ~70 | Cross-resource authorization, Special cases, Email validation |
+| LiveView Tests | ~25 | UI rendering, Page permissions, Conditional elements |
+| E2E Journey Tests | ~5 | Complete user flows (one per role) |
+
+### What to Test (Focus on Behavior)
+
+**DO Test:**
+- Permission lookups return correct results
+- Policies allow/deny actions correctly
+- Scope filters work (own, linked, all)
+- UI elements show/hide based on permissions
+- Page access is controlled
+- Special cases work (email, system roles)
+- Error handling (no crashes)
+
+**DON'T Test:**
+- Database schema existence
+- Table columns (Ash generates these)
+- Implementation details
+- Private functions (test through public API)
+
+### Test Files Structure
+
+```
+test/
+├── mv/
+│ └── authorization/
+│ ├── permission_sets_test.exs # Issue #2
+│ ├── role_test.exs # Issue #1 (smoke)
+│ └── checks/
+│ └── has_permission_test.exs # Issue #6
+├── mv/accounts/
+│ └── user_policies_test.exs # Issue #8
+├── mv/membership/
+│ ├── member_policies_test.exs # Issue #7
+│ ├── member_email_validation_test.exs # Issue #12
+│ ├── property_policies_test.exs # Issue #9
+│ └── property_type_policies_test.exs # Issue #10
+├── mv_web/
+│ ├── authorization_test.exs # Issue #14
+│ ├── plugs/
+│ │ └── check_page_permission_test.exs # Issue #11
+│ └── live/
+│ ├── role_live_test.exs # Issue #3
+│ ├── role_live_authorization_test.exs # Issue #15
+│ ├── member_live_authorization_test.exs # Issue #16
+│ ├── user_live_authorization_test.exs # Issue #16
+│ ├── property_live_authorization_test.exs # Issue #16
+│ └── property_type_live_authorization_test.exs # Issue #16
+├── integration/
+│ ├── mitglied_journey_test.exs # Issue #17
+│ ├── vorstand_journey_test.exs # Issue #17
+│ ├── kassenwart_journey_test.exs # Issue #17
+│ ├── buchhaltung_journey_test.exs # Issue #17
+│ ├── admin_journey_test.exs # Issue #17
+│ └── special_cases_test.exs # Issue #17
+└── seeds/
+ └── authorization_seeds_test.exs # Issue #13
+```
+
+---
+
+## Migration & Rollback
+
+### Database Migrations
+
+**Issue #1 creates one migration:**
+
+```elixir
+# priv/repo/migrations/TIMESTAMP_add_authorization.exs
+defmodule Mv.Repo.Migrations.AddAuthorization do
+ use Ecto.Migration
+
+ def up do
+ # Create roles table
+ create table(:roles, primary_key: false) do
+ add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
+ add :name, :string, null: false
+ add :description, :text
+ add :permission_set_name, :string, null: false
+ add :is_system_role, :boolean, default: false, null: false
+
+ timestamps()
+ end
+
+ create unique_index(:roles, [:name])
+ create index(:roles, [:permission_set_name])
+
+ # Add role_id to users table
+ alter table(:users) do
+ add :role_id, references(:roles, type: :binary_id, on_delete: :restrict)
+ end
+
+ create index(:users, [:role_id])
+ end
+
+ def down do
+ drop index(:users, [:role_id])
+
+ alter table(:users) do
+ remove :role_id
+ end
+
+ drop table(:roles)
+ end
+end
+```
+
+### Data Migration (Seeds)
+
+**After migration applied:**
+
+Run seeds to create roles and assign defaults:
+
+```bash
+mix run priv/repo/seeds/authorization_seeds.exs
+```
+
+### Rollback Plan
+
+**If issues discovered in production:**
+
+1. **Immediate Rollback:**
+ - Set `ENABLE_RBAC=false` environment variable
+ - Restart application
+ - Old authorization system takes over instantly
+
+2. **Database Rollback (if needed):**
+ ```bash
+ mix ecto.rollback --step 1
+ ```
+ - Removes `role_id` from users
+ - Removes `roles` table
+ - Existing auth untouched
+
+3. **Code Rollback:**
+ - Revert Git commit
+ - Redeploy previous version
+
+**Rollback Safety:**
+- No existing tables modified (only additions)
+- Feature flag allows instant disable
+- Old auth code remains in place until RBAC proven stable
+
+---
+
+## Risk Management
+
+### Identified Risks
+
+| Risk | Probability | Impact | Mitigation |
+|------|-------------|--------|------------|
+| **Policy order issues** | Medium | High | Clear documentation, strict order enforcement, integration tests verify policies work together |
+| **Scope filter errors** | Medium | High | TDD approach, extensive scope tests (own/linked/all), test with all resource types |
+| **UI/Policy divergence** | Low | Medium | UI helpers use same PermissionSets module as policies, shared logic, integration tests verify consistency |
+| **Breaking existing auth** | Low | High | Feature flag allows instant rollback, parallel systems until proven, gradual rollout |
+| **User without role edge case** | Low | Medium | Default "Mitglied" role assigned in seeds, validation on User.create, tests cover nil role |
+| **Invalid permission_set_name** | Low | Low | Validation on Role resource, tests cover invalid names, error handling throughout |
+| **Performance (not a concern)** | Very Low | Low | Hardcoded permissions are < 1 microsecond, no DB queries, no cache needed |
+
+### Edge Cases Handled
+
+**User without role:**
+- Default: Access denied (no permissions)
+- Seeds assign "Mitglied" to all existing users
+- New users must be assigned role on creation
+
+**Invalid permission_set_name:**
+- Role validation prevents creation
+- Runtime checks handle gracefully (return false/error, no crash)
+- Error logged for debugging
+
+**System role protection:**
+- Cannot delete role with `is_system_role=true`
+- UI hides delete button
+- Backend validation prevents deletion
+- "Mitglied" is system role by default
+
+**Linked member email:**
+- Custom validation on Member resource
+- Only admins can edit if member.user_id present
+- Prevents breaking email synchronization
+
+**Missing actor context:**
+- All policies check for actor presence
+- Missing actor = access denied
+- No crashes, graceful error handling
+
+### Performance Considerations
+
+**No concerns for MVP:**
+- Hardcoded permissions are pure function calls
+- No database queries for permission checks
+- Pattern matching on small lists (< 50 items total)
+- Typical check: < 1 microsecond
+- Can handle 10,000+ requests/second easily
+
+**Future considerations (Phase 3):**
+- If migrating to database-backed: add ETS cache
+- Cache invalidation on role/permission changes
+- Database indexes on permission tables
+
+---
+
+## Success Criteria
+
+**MVP is successful when:**
+
+- [ ] All 15 issues completed
+- [ ] All 180+ tests passing
+- [ ] Zero linter errors
+- [ ] Manual testing completed for all 5 roles
+- [ ] Integration tests verify complete user journeys
+- [ ] Feature flag tested (on/off states)
+- [ ] Documentation complete
+- [ ] Code review approved
+- [ ] Deployed to staging and verified
+- [ ] Performance verified (< 100ms per page load)
+- [ ] No authorization bypasses found in security review
+
+**Ready for Production when:**
+
+- [ ] 1 week in staging with no critical issues
+- [ ] All stakeholders have tested their role types
+- [ ] Rollback plan tested
+- [ ] Monitoring/alerting configured
+- [ ] Runbook created for common issues
+
+---
+
+## Next Steps After MVP
+
+**Phase 2: Field-Level Permissions (Future - 2-3 weeks)**
+
+- Extend PermissionSets with `:fields` key
+- Implement Ash Calculations to filter readable fields
+- Implement Custom Validations for writable fields
+- No database changes needed
+- See [Architecture Document](./roles-and-permissions-architecture.md) for details
+
+**Phase 3: Database-Backed Permissions (Future - 3-4 weeks)**
+
+- Create `permission_sets`, `permission_set_resources`, `permission_set_pages` tables
+- Replace hardcoded PermissionSets module with DB queries
+- Implement ETS cache for performance
+- Allow runtime permission configuration
+- See [Architecture Document](./roles-and-permissions-architecture.md) for migration strategy
+
+---
+
+## Document History
+
+| Version | Date | Author | Changes |
+|---------|------|--------|---------|
+| 1.0 | 2025-01-12 | AI Assistant | Initial version with DB-backed permissions |
+| 2.0 | 2025-01-13 | AI Assistant | Complete rewrite for hardcoded MVP, removed all V1 references, fixed Buchhaltung inconsistency |
+
+---
+
+## Appendix
+
+### Glossary
+
+- **Permission Set:** A named collection of resource and page permissions (e.g., "admin", "read_only")
+- **Role:** A database entity that links users to a permission set
+- **Scope:** The range of records a permission applies to (:own, :linked, :all)
+- **Actor:** The currently authenticated user in Ash authorization context
+- **System Role:** A role that cannot be deleted (is_system_role=true)
+
+### Key Files
+
+- `lib/mv/authorization/permission_sets.ex` - Core permissions logic
+- `lib/mv/authorization/checks/has_permission.ex` - Ash policy check
+- `lib/mv_web/authorization.ex` - UI helper functions
+- `lib/mv_web/plugs/check_page_permission.ex` - Page access control
+- `priv/repo/seeds/authorization_seeds.exs` - Role seed data
+
+### Useful Commands
+
+```bash
+# Run all authorization tests
+mix test test/mv/authorization
+
+# Run integration tests only
+mix test test/integration
+
+# Run with coverage
+mix test --cover
+
+# Generate migrations after Ash resource changes
+mix ash.codegen
+
+# Run seeds
+mix run priv/repo/seeds/authorization_seeds.exs
+
+# Check for linter errors
+mix credo --strict
+```
+
+---
+
+**End of Implementation Plan**
+
diff --git a/docs/roles-and-permissions-overview.md b/docs/roles-and-permissions-overview.md
new file mode 100644
index 0000000..191e8b7
--- /dev/null
+++ b/docs/roles-and-permissions-overview.md
@@ -0,0 +1,506 @@
+# Roles and Permissions - Architecture Overview
+
+**Project:** Mila - Membership Management System
+**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
+**Version:** 2.0
+**Last Updated:** 2025-11-13
+**Status:** Architecture Design - MVP Approach
+
+---
+
+## Purpose of This Document
+
+This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
+
+**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
+
+---
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Requirements Summary](#requirements-summary)
+3. [Evaluated Approaches](#evaluated-approaches)
+4. [Selected Architecture](#selected-architecture)
+5. [Permission System Design](#permission-system-design)
+6. [User-Member Linking Strategy](#user-member-linking-strategy)
+7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
+8. [Migration Strategy](#migration-strategy)
+9. [Related Documents](#related-documents)
+
+---
+
+## Overview
+
+The Mila membership management system requires a flexible authorization system that controls:
+- **Who** can access **what** resources
+- **Which** pages users can view
+- **How** users interact with their own vs. others' data
+
+### Key Design Principles
+
+1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
+2. **Performance:** No database queries for permission checks in MVP
+3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
+4. **Security:** Explicit action-based authorization with no ambiguity
+5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
+
+### Core Concepts
+
+**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
+
+**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
+
+**User:** Each user has exactly one Role, inheriting that Role's Permission Set
+
+**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
+
+---
+
+## Evaluated Approaches
+
+During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
+
+### Approach 1: JSONB in Roles Table
+
+Store all permissions as a single JSONB column directly in the roles table.
+
+**Advantages:**
+- Simplest database schema (single table)
+- Very flexible structure
+- No additional tables needed
+- Fast to implement
+
+**Disadvantages:**
+- Poor queryability (can't efficiently filter by specific permissions)
+- No referential integrity
+- Difficult to validate structure
+- Hard to audit permission changes
+- Can't leverage database indexes effectively
+
+**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
+
+---
+
+### Approach 2: Normalized Database Tables
+
+Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
+
+**Advantages:**
+- Fully queryable with SQL
+- Runtime configurable permissions
+- Strong referential integrity
+- Easy to audit changes
+- Can index for performance
+
+**Disadvantages:**
+- Complex database schema (4+ tables)
+- DB queries required for every permission check
+- Requires ETS cache for performance
+- Needs admin UI for permission management
+- Longer implementation time (4-5 weeks)
+- Overkill for fixed set of 4 permission sets
+
+**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
+
+---
+
+### Approach 3: Custom Authorizer
+
+Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
+
+**Advantages:**
+- Complete control over authorization logic
+- Can implement any custom behavior
+- Not constrained by Ash Policy DSL
+
+**Disadvantages:**
+- Significantly more code to write and maintain
+- Loses benefits of Ash's declarative policies
+- Harder to test than built-in policy system
+- Mixes declarative and imperative approaches
+- Must reimplement filter generation for queries
+- Higher bug risk
+
+**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
+
+---
+
+### Approach 4: Simple Role Enum
+
+Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
+
+**Advantages:**
+- Very simple to implement (< 1 week)
+- No extra tables needed
+- Fast performance
+- Easy to understand
+
+**Disadvantages:**
+- No separation between roles and permissions
+- Can't add new roles without code changes
+- No dynamic permission configuration
+- Not extensible to field-level permissions
+- Violates separation of concerns (role = job function, not permission set)
+- Difficult to maintain as requirements grow
+
+**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
+
+---
+
+### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
+
+Permission Sets hardcoded in Elixir module, only Roles table in database.
+
+**Advantages:**
+- Fast implementation (2-3 weeks vs 4-5 weeks)
+- Maximum performance (zero DB queries, < 1 microsecond)
+- Simple to test (pure functions)
+- Code-reviewable permissions (visible in Git)
+- No migration needed for existing data
+- Clearly defined 4 permission sets as required
+- Clear migration path to database-backed solution (Phase 3)
+- Maintains separation of roles and permission sets
+
+**Disadvantages:**
+- Permissions not editable at runtime (only role assignment possible)
+- New permissions require code deployment
+- Not suitable if permissions change frequently (> 1x/week)
+- Limited to the 4 predefined permission sets
+
+**Why Selected:**
+- MVP requirement is for 4 fixed permission sets (not custom ones)
+- No stated requirement for runtime permission editing
+- Performance is critical for authorization checks
+- Fast time-to-market (2-3 weeks)
+- Clear upgrade path when runtime configuration becomes necessary
+
+**Migration Path:**
+When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
+
+---
+
+## Requirements Summary
+
+### Four Predefined Permission Sets
+
+1. **own_data** - Access only to own user account and linked member profile
+2. **read_only** - Read access to all members and custom fields
+3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
+4. **admin** - Unrestricted access to all resources including user management
+
+### Example Roles
+
+- **Mitglied (Member)** - Uses "own_data" permission set, default role
+- **Vorstand (Board)** - Uses "read_only" permission set
+- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
+- **Buchhaltung (Accounting)** - Uses "read_only" permission set
+- **Admin** - Uses "admin" permission set
+
+### Authorization Levels
+
+**Resource Level (MVP):**
+- Controls create, read, update, destroy actions on resources
+- Resources: Member, User, Property, PropertyType, Role
+
+**Page Level (MVP):**
+- Controls access to LiveView pages
+- Example: "/members/new" requires Member.create permission
+
+**Field Level (Phase 2 - Future):**
+- Controls read/write access to specific fields
+- Example: Only Treasurer can see payment_history field
+
+### Special Cases
+
+1. **Own Credentials:** Users can always edit their own email and password
+2. **Linked Member Email:** Only admins can edit email of members linked to users
+3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
+
+---
+
+## Selected Architecture
+
+### Conceptual Model
+
+```
+Elixir Module: PermissionSets
+ ↓ (defines)
+Permission Set (:own_data, :read_only, :normal_user, :admin)
+ ↓ (referenced by)
+Role (stored in DB: "Vorstand" → "read_only")
+ ↓ (assigned to)
+User (each user has one role_id)
+```
+
+### Database Schema (MVP)
+
+**Single Table: roles**
+
+Contains:
+- id (UUID)
+- name (e.g., "Vorstand")
+- description
+- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
+- is_system_role (boolean, protects critical roles)
+
+**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
+
+### Why This Approach?
+
+**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
+
+**Maximum Performance:**
+- Zero database queries for permission checks
+- Pure function calls (< 1 microsecond)
+- No caching needed
+
+**Code Review:**
+- Permissions visible in Git diffs
+- Easy to review changes
+- No accidental runtime modifications
+
+**Clear Upgrade Path:**
+- Phase 1 (MVP): Hardcoded
+- Phase 2: Add field-level permissions
+- Phase 3: Migrate to database-backed with admin UI
+
+**Meets Requirements:**
+- Four predefined permission sets ✓
+- Dynamic role creation ✓ (Roles in DB)
+- Role-to-user assignment ✓
+- No requirement for runtime permission changes stated
+
+---
+
+## Permission System Design
+
+### Permission Structure
+
+Each Permission Set contains:
+
+**Resources:** List of resource permissions
+- resource: "Member", "User", "Property", etc.
+- action: :read, :create, :update, :destroy
+- scope: :own, :linked, :all
+- granted: true/false
+
+**Pages:** List of accessible page paths
+- Examples: "/", "/members", "/members/:id/edit"
+- "*" for admin (all pages)
+
+### Scope Definitions
+
+**:own** - Only records where id == actor.id
+- Example: User can read their own User record
+
+**:linked** - Only records where user_id == actor.id
+- Example: User can read Member linked to their account
+
+**:all** - All records without restriction
+- Example: Admin can read all Members
+
+### How Authorization Works
+
+1. User attempts action on resource (e.g., read Member)
+2. System loads user's role from database
+3. Role contains permission_set_name string
+4. PermissionSets module returns permissions for that set
+5. Custom Policy Check evaluates permissions against action
+6. Access granted or denied based on scope
+
+### Custom Policy Check
+
+A reusable Ash Policy Check that:
+- Reads user's permission_set_name from their role
+- Calls PermissionSets.get_permissions/1
+- Matches resource + action against permissions list
+- Applies scope filters (own/linked/all)
+- Returns authorized, forbidden, or filtered query
+
+---
+
+## User-Member Linking Strategy
+
+### Problem Statement
+
+Users need to create member profiles for themselves (self-service), but only admins should be able to:
+- Link existing members to users
+- Unlink members from users
+- Create members pre-linked to arbitrary users
+
+### Selected Approach: Separate Ash Actions
+
+Instead of complex field-level validation, we use action-based authorization.
+
+### Actions on Member Resource
+
+**1. create_member_for_self** (All authenticated users)
+- Automatically sets user_id = actor.id
+- User cannot specify different user_id
+- UI: "Create My Profile" button
+
+**2. create_member** (Admin only)
+- Can set user_id to any user or leave unlinked
+- Full flexibility for admin
+- UI: Admin member management form
+
+**3. link_member_to_user** (Admin only)
+- Updates existing member to set user_id
+- Connects unlinked member to user account
+
+**4. unlink_member_from_user** (Admin only)
+- Sets user_id to nil
+- Disconnects member from user account
+
+**5. update** (Permission-based)
+- Normal updates (name, address, etc.)
+- user_id NOT in accept list (prevents manipulation)
+- Available to users with Member.update permission
+
+### Why Separate Actions?
+
+**Explicit Semantics:** Each action has clear, single purpose
+
+**Server-Side Security:** user_id set by server, not client input
+
+**Better UX:** Different UI flows for different use cases
+
+**Simple Policies:** Authorization at action level, not field level
+
+**Easy Testing:** Each action independently testable
+
+---
+
+## Field-Level Permissions Strategy
+
+### Status: Phase 2 (Future Implementation)
+
+Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
+
+### Problem Statement
+
+Some scenarios require field-level control:
+- **Read restrictions:** Hide payment_history from certain roles
+- **Write restrictions:** Only treasurer can edit payment fields
+- **Complexity:** Ash Policies work at resource level, not field level
+
+### Selected Strategy
+
+**For Read Restrictions:**
+Use Ash Calculations or Custom Preparations
+- Calculations: Dynamically compute field based on permissions
+- Preparations: Filter select to only allowed fields
+- Field returns nil or "[Hidden]" if unauthorized
+
+**For Write Restrictions:**
+Use Custom Validations
+- Validate changeset against field permissions
+- Similar to existing linked-member email validation
+- Return error if field modification not allowed
+
+### Why This Strategy?
+
+**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
+
+**Performance:** Calculations are lazy, Preparations run once per query
+
+**Maintainable:** Clear validation logic, standard Ash patterns
+
+**Extensible:** Easy to add new field restrictions
+
+### Implementation Timeline
+
+**Phase 1 (MVP):** No field-level permissions
+
+**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
+
+**Phase 3:** If migrating to database, add permission_set_fields table
+
+---
+
+## Migration Strategy
+
+### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
+
+**What's Included:**
+- Roles table in database
+- PermissionSets Elixir module with 4 predefined sets
+- Custom Policy Check reading from module
+- UI Authorization Helpers for LiveView
+- Admin UI for role management (create, assign, delete roles)
+
+**Limitations:**
+- Permissions not editable at runtime
+- New permissions require code deployment
+- Only 4 permission sets available
+
+**Benefits:**
+- Fast implementation
+- Maximum performance
+- Simple testing and review
+
+### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
+
+**When Needed:** Business requires field-level restrictions
+
+**Implementation:**
+- Extend PermissionSets module with :fields key
+- Add Ash Calculations for read restrictions
+- Add custom validations for write restrictions
+- Update UI Helpers
+
+**Migration:** No database changes, pure code additions
+
+### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
+
+**When Needed:** Runtime permission configuration required
+
+**Implementation:**
+- Create permission tables in database
+- Seed script to migrate hardcoded permissions
+- Update PermissionSets module to query database
+- Add ETS cache for performance
+- Build admin UI for permission management
+
+**Migration:** Seamless, no changes to existing Policies or UI code
+
+### Decision Matrix: When to Migrate?
+
+| Scenario | Recommended Phase |
+|----------|-------------------|
+| MVP with 4 fixed permission sets | Phase 1 |
+| Need field-level restrictions | Phase 2 |
+| Permission changes < 1x/month | Stay Phase 1 |
+| Need runtime permission config | Phase 3 |
+| Custom permission sets needed | Phase 3 |
+| Permission changes > 1x/week | Phase 3 |
+
+---
+
+## Related Documents
+
+**This Document (Overview):** High-level concepts, no code examples
+
+**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
+
+**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
+
+**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
+
+---
+
+## Summary
+
+The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
+- **Speed:** 2-3 weeks implementation vs 4-5 weeks
+- **Performance:** Zero database queries for authorization
+- **Clarity:** Permissions in Git, reviewable and testable
+- **Flexibility:** Clear migration path to database-backed system
+
+**User-Member linking** uses **separate Ash Actions** for clarity and security.
+
+**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
+
+The approach balances pragmatism for MVP delivery with extensibility for future requirements.
+
diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex
index 176edc8..ab8f104 100644
--- a/lib/mv_web/live/custom_field_live/form.ex
+++ b/lib/mv_web/live/custom_field_live/form.ex
@@ -19,9 +19,6 @@ defmodule MvWeb.CustomFieldLive.Form do
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this custom field (default: false)
- **Read-only (Edit mode only):**
- - slug - Auto-generated URL-friendly identifier (immutable)
-
## Value Type Selection
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data
@@ -52,19 +49,6 @@ defmodule MvWeb.CustomFieldLive.Form do
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
- <%!-- Show slug in edit mode (read-only) --%>
-