diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md
deleted file mode 100644
index fa45d86..0000000
--- a/docs/roles-and-permissions-architecture.md
+++ /dev/null
@@ -1,2502 +0,0 @@
-# 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
deleted file mode 100644
index 0b173fa..0000000
--- a/docs/roles-and-permissions-implementation-plan.md
+++ /dev/null
@@ -1,1653 +0,0 @@
-# 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
deleted file mode 100644
index 191e8b7..0000000
--- a/docs/roles-and-permissions-overview.md
+++ /dev/null
@@ -1,506 +0,0 @@
-# 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 ab8f104..176edc8 100644
--- a/lib/mv_web/live/custom_field_live/form.ex
+++ b/lib/mv_web/live/custom_field_live/form.ex
@@ -19,6 +19,9 @@ 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
@@ -49,6 +52,19 @@ 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) --%>
+