# Roles and Permissions Architecture - Technical Specification

**Version:** 2.0 (Clean Rewrite)  
**Date:** 2025-01-13  
**Last Updated:** 2026-01-13  
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)  
**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)
- CustomFieldValue (custom field values)
- CustomField (custom field definitions)
- Role (role management)
- Group (group definitions; read all, create/update/destroy normal_user and admin)
- MemberGroup (member–group associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
- MembershipFeeCycle (fee cycles; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
- JoinRequest (membership join requests; normal_user read+update, admin full CRUD)

**4. Page-Level Permissions**

Control access to LiveView pages:
- Index pages (list views)
- Show pages (detail views)
- Form pages (create/edit)
- Admin pages
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)

**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: `id == user.member_id` (User.member_id → Member.id, inverse relationship)
  - CustomFieldValue: `member_id == user.member_id` (traverses Member → User relationship)
- **:all** - All records, no filtering

**6. Special Cases**

- **Own Credentials:** Every user can always read/update their own credentials
- **Linked Member Email:** Only administrators or the linked user can change the email for members linked to users (see `Mv.Membership.Member.Validations.EmailChangePermission`)
- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag)
- **User-Member Linking:** Only admins can link/unlink users and members
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).

**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 (2-3 weeks vs. 4-5 for DB-backed), maximum performance (< 1μs per
check, no permission queries/joins/cache), Git-tracked permission changes, deterministic
functional tests, and a well-defined Phase 3 migration path.

**Trade-offs:** Permission changes need a code deployment and new sets cannot be added without
a code change — acceptable for the MVP, which specifies 4 fixed sets with rare changes.

### 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. The SQL below is
**illustrative** — see `priv/repo/migrations/*_add_authorization_domain.exs` for the exact DDL
(notably the primary key uses the custom `uuid_generate_v7()` SQL function, and the unique index
is named `roles_unique_name_index`).

```sql
CREATE TABLE roles (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
  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_unique_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

The five predefined roles are seeded in `priv/repo/seeds_bootstrap.exs` (the canonical source —
do not duplicate the data here). Each role is created idempotently via the Role resource's
`:create_role_with_system_flag` action, and the seeds map roles to permission sets as:

| Role | permission_set_name | is_system_role |
|------|---------------------|----------------|
| Mitglied | own_data | true (cannot be deleted) |
| Vorstand | read_only | false |
| Kassenwart | normal_user | false |
| Buchhaltung | read_only | false |
| Admin | admin | false |

Assigning the default "Mitglied" role to users that have no role is handled separately by the
`assign_mitglied_role_to_existing_users` migration, not by the seed script.

---

## 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, as pure compile-time functions (lookups < 1μs).

**Types & public API:**

- `@type scope :: :own | :linked | :all`, `@type action :: :read | :create | :update | :destroy`
- A `resource_permission` is `%{resource: String.t(), action:, scope:, granted: boolean}`;
  a `permission_set` is `%{resources: [resource_permission], pages: [String.t()]}`.
- `all_permission_sets/0` → `[:own_data, :read_only, :normal_user, :admin]`.
- `get_permissions/1` — one function clause per set returning its `%{resources, pages}` map.
  An unknown atom raises `ArgumentError` (callers always go through the conversion below).
- `valid_permission_set?/1` — accepts string or atom; the string clause delegates to the
  converter; the atom clause checks membership in `all_permission_sets/0`.
- `permission_set_name_to_atom/1` — `String.to_existing_atom/1` guarded by validity, and
  **rescues `ArgumentError`** (unknown string → never-created atom) returning
  `{:error, :invalid_permission_set}`. This is the safe entry point used everywhere.

**Resource permissions per set** are exactly the Permission Matrix below. Note `normal_user`
intentionally omits `Member :destroy` (safety); `own_data` has full CRUD on its linked
CustomFieldValues; all four sets grant `User read/update :own`.

**Pages per set:** the exact `pages` lists live in the `get_permissions/1` clauses of
`Mv.Authorization.PermissionSets` (single source of truth). Key facts that shape the lists:

- **own_data:** deliberately does **not** include `/` (Mitglied must not see the member index at
  root, which has the same content as `/members`). Self-service pages are `/users/:id`,
  `/users/:id/edit`, `/users/:id/show/edit`; linked-member pages are `/members/:id`,
  `/members/:id/edit`, `/members/:id/show/edit` (data access filtered by policy scope `:linked`).
- **read_only / normal_user:** include `/` plus the self-service `/users/:id…` pages and their
  respective member / custom-field-value / group pages; normal_user additionally has the create/edit
  pages and the `/join_requests` approval pages.
- **admin:** `"*"` wildcard (all pages), with `/settings` and `/membership_fee_settings` also listed
  explicitly.

There is no `/profile` route; the self-service profile pages are the `/users/:id…` routes above.

#### 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 |
| **CustomFieldValue** (linked) | R, U, C, D | - | - | - |
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D |
| **Group** (all) | R | R | R, C, U, D | R, C, U, D |
| **MemberGroup** (linked) | R | - | - | - |
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
| **MembershipFeeCycle** (linked) | R | - | - | - |
| **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D |
| **JoinRequest** (all) | - | - | R, U | 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: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
    - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
  
  ## 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
  # ...
end
```

**`match?/3` logic.** With-chain: read `actor.role.permission_set_name` (non-nil) →
`PermissionSets.permission_set_name_to_atom/1` → `get_permissions/1` → resource name via
`Module.split() |> List.last()`. Find the granted permission matching resource+action; if none,
`{:error, :no_permission}`; otherwise apply the scope filter. The `else` clauses log and return a
specific reason — `{:error, :no_role}` (role nil), `{:error, :no_permission_set}`
(permission_set_name nil), `{:error, :invalid_permission_set}`, or `{:error, :no_permission}`
(no actor/missing data). Every error results in Forbidden (fail-closed).

**Scope filters (`apply_scope/3`):**

- `:all` → `:authorized` (no filter)
- `:own` → `{:filter, expr(id == ^actor.id)}` (User: own record)
- `:linked` → resource-specific:
  - `"Member"` → `{:filter, expr(id == ^actor.member_id)}` (User.member_id → Member.id, inverse)
  - `"CustomFieldValue"` → `{:filter, expr(member_id == ^actor.member_id)}` (traverses CFV.member_id → Member → User.member_id)
  - fallback → `{:filter, expr(user_id == ^actor.id)}`

**Key Design Decisions:**

1. **Resource-Specific :linked Scope:** CustomFieldValue 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.

---

## Bypass vs. HasPermission

For filter-based permissions (`scope :own`, `scope :linked`) the resources use a two-tier
pattern: **bypass with `expr()` for READ** (Ash does not reliably trigger `auto_filter`
when `HasPermission`'s `strict_check` returns `{:ok, false}` on record-less list queries),
and **HasPermission for UPDATE/CREATE/DESTROY** (a changeset record is present, so scope is
evaluated correctly). The scope concept stays meaningful — bypass is only a workaround for
Ash's auto_filter limitation, not a replacement for it.

The full rationale, the per-operation decision table, and why both `User` and `Member`
follow this pattern are documented in the canonical
[policy-bypass-vs-haspermission.md](./policy-bypass-vs-haspermission.md).

---

### User Resource Policies

**Location:** `lib/accounts/user.ex`

**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own).

**Key Insight:** Bypass with `expr()` is needed ONLY for READ list queries because HasPermission's strict_check cannot properly trigger auto_filter. UPDATE operations work correctly via HasPermission because a changeset with record is available.

```elixir
defmodule Mv.Accounts.User do
  use Ash.Resource, ...
  
  policies do
    # 1. AshAuthentication Bypass (registration/login without actor)
    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
      authorize_if always()
    end
    
    # 2. SPECIAL CASE: Users can always READ their own account
    # Bypass needed for list queries (expr() triggers auto_filter in Ash)
    # UPDATE is handled by HasPermission below (scope :own works with changesets)
    bypass action_type(:read) do
      description "Users can always read their own account"
      authorize_if expr(id == ^actor(:id))
    end
    
    # 3. GENERAL: Check permissions from user's role
    # - :own_data → can UPDATE own user (scope :own via HasPermission)
    # - :read_only → can UPDATE own user (scope :own via HasPermission)
    # - :normal_user → can UPDATE own user (scope :own via HasPermission)
    # - :admin → can read/create/update/destroy all users (scope :all)
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role and permission set"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    
    # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
  end
  
  # ...
end
```

**Why Bypass for READ but not UPDATE?**

- **READ list queries** (`Ash.read(User, actor: user)`): No record at strict_check time → HasPermission returns `{:ok, false}` → auto_filter not called → bypass with `expr()` needed ✅
- **UPDATE operations** (`Ash.update(changeset, actor: user)`): Changeset contains record → HasPermission can evaluate `scope :own` correctly → works via HasPermission ✅

**Permission Matrix:**

| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read own | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (scope :all) |
| Update own | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :all) |
| Read others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
| Update others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
| Create | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |

**Note:** This pattern is consistent with Member resource policies (bypass for READ, HasPermission for UPDATE).

### Member Resource Policies

**Location:** `lib/membership/member.ex`

**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked).

**Key Insight:** Same pattern as User - bypass with `expr()` is needed ONLY for READ list queries. UPDATE operations work correctly via HasPermission because a changeset with record is available.

```elixir
defmodule Mv.Membership.Member do
  use Ash.Resource, ...
  
  policies do
    # 1. SPECIAL CASE: Users can always READ their linked member
    # Bypass needed for list queries (expr() triggers auto_filter in Ash)
    # UPDATE is handled by HasPermission below (scope :linked works with changesets)
    bypass action_type(:read) do
      description "Users can always read member linked to their account"
      authorize_if expr(id == ^actor(:member_id))
    end
    
    # 2. READ/DESTROY: Check permissions only (no :user argument on these actions)
    policy action_type([:read, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end

    # 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions
    # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
    # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all
    policy action_type([:create, :update]) do
      description "Forbid user link unless admin; then check permissions"
      forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
      authorize_if Mv.Authorization.Checks.HasPermission
    end

    # 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
  end
  
  # Linked-member email editing is enforced by a dedicated Validations module
  # (see Special Cases section)
  validations do
    validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]
  end
  
  # ...
end
```

**Why Bypass for READ but not UPDATE?**

- **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅

**User–member link:** Only admins may pass the `:user` argument on create_member or update_member (link or unlink via `user: nil`/`user: %{}`). The check uses **argument presence** (key in arguments), not value, to avoid bypass (see [User-Member Linking](#user-member-linking)).

**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)

### CustomFieldValue Resource Policies

**Location:** `lib/membership/custom_field_value.ex`

**Pattern:** Bypass for READ (list queries), CustomFieldValueCreateScope for create (no filter), HasPermission for read/update/destroy. Create uses a dedicated check because Ash cannot apply filters to create actions.

The bypass `action_type(:read)` is a production-side rule: reading own CFVs (where `member_id == actor.member_id`) is always allowed and overrides Permission-Sets; no further policies are needed for that. It applies to all read actions (get, list, load).

```elixir
defmodule Mv.Membership.CustomFieldValue do
  use Ash.Resource, ...
  
  policies do
    # Bypass for READ (list queries; expr triggers auto_filter)
    bypass action_type(:read) do
      description "Users can read custom field values of their linked member"
      authorize_if expr(member_id == ^actor(:member_id))
    end
    
    # CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create)
    # own_data -> create when member_id == actor.member_id; normal_user/admin -> create (scope :all)
    policy action_type(:create) do
      authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope
    end
    
    # READ/UPDATE/DESTROY: HasPermission (scope :linked / :all)
    policy action_type([:read, :update, :destroy]) do
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    # DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed)
  end
end
```

**Permission Matrix:**

| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read linked | ✅ (bypass) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ |
| Update linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ |
| Create linked | ✅ (CustomFieldValueCreateScope) | ❌ | ✅ | ❌ | ✅ |
| Destroy linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ |
| Read all | ❌ | ✅ | ✅ | ✅ | ✅ |
| Create all | ❌ | ❌ | ✅ | ❌ | ✅ |
| Destroy all | ❌ | ❌ | ✅ | ❌ | ✅ |

### CustomField Resource Policies

**Location:** `lib/membership/custom_field.ex`

**No Special Cases:** All users can read, only admin can write.

```elixir
defmodule Mv.Membership.CustomField do
  use Ash.Resource,
    domain: Mv.Membership,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  policies do
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
  end
  # ...
end
```

**Permission Matrix:**

| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read | ✅ | ✅ | ✅ | ✅ | ✅ |
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
| Update | ❌ | ❌ | ❌ | ❌ | ✅ |
| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |

### Role Resource Policies

**Location:** `lib/mv/authorization/role.ex`

**Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary.

**Special Protection:** System roles cannot be deleted (validation on destroy).

```elixir
defmodule Mv.Authorization.Role do
  use Ash.Resource,
    authorizers: [Ash.Policy.Authorizer]
  
  policies do
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role (read all, create/update/destroy admin only)"
      authorize_if Mv.Authorization.Checks.HasPermission
    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`

### User Role Assignment (Admin-Only)

**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`

Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.

### Group Resource Policies

**Location:** `lib/membership/group.ex`

Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets).

### MemberGroup Resource Policies

**Location:** `lib/membership/member_group.ex`

Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply).

### MembershipFeeType Resource Policies

**Location:** `lib/membership_fees/membership_fee_type.ex`

Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.

### MembershipFeeCycle Resource Policies

**Location:** `lib/membership_fees/membership_fee_cycle.ex`

Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side.

---

## 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, **before** the LiveView mounts.

**Behavior (`lib/mv_web/plugs/check_page_permission.ex`):**

1. Extracts the page path as the **route template** (e.g. `/members/:id`) via
   `Phoenix.Router.route_info/4`, falling back to `conn.request_path`. Using the template,
   not the concrete path, is what lets the permission `pages` lists stay parameterized.
   (Public paths such as `/sign-in`, `/register`, `/auth/*` are exempt and pass through.)
2. Reads `current_user` from `conn.assigns`, resolves its `permission_set_name`, and looks up
   the allowed `pages` via `PermissionSets.get_permissions/1`.
3. Matches the path against allowed patterns: `*` wildcard (admin), exact match, or
   segment-wise dynamic match where a `:`-prefixed pattern segment matches any path segment
   (same segment count required).
4. On no match (including nil user, no role, or invalid permission set → false): logs the
   denial and **redirects to `/users/:id`** (the logged-in user's own profile) or, when there is
   no user, to **`/sign-in`**, then halts. The `"You don't have permission to access this page."`
   flash is set only for a logged-in user; an unauthenticated visitor is redirected without a flash.

### 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: `/users/123` (own profile), `/members/123` (if 123 is their linked member)
- ❌ Cannot access: `/` (root member index is excluded for own_data), `/members`, `/members/new`, `/settings`

**Vorstand (read_only):**
- ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/users/123` (own profile)
- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/settings`

**Kassenwart (normal_user):**
- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/join_requests`, `/users/123` (own profile)
- ❌ Cannot access: `/settings`, `/membership_fee_settings`

**Admin:**
- ✅ Can access: `*` (all pages, including `/settings` and `/membership_fee_settings`)

---

## 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,
reading from the **same** `PermissionSets` module as the backend policies so UI and backend
stay consistent (pure function calls, no DB queries). Imported into `mv_web.ex` `html_helpers`
so every LiveView has it: `import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]`.

**Public functions:**

- `can?/3` (resource atom) — `can?(user, action, Mv.Membership.Member)`: true iff the user's
  permission set grants `action` on that resource (any scope).
- `can?/3` (record struct) — `can?(user, action, %Member{})`: finds the matching permission,
  then applies the scope check against the record:
  - `:all` → always true
  - `:own` → `record.id == user.id`
  - `:linked` → resource-specific: Member checks `record.user_id == user.id`; CustomFieldValue
    traverses `record.member.user_id == user.id` (member must be preloaded), with a `user_id`
    fallback for other resources.
- `can_access_page?/2` — matches the path against the permission set's `pages` list using the
  same rules as the plug: `*` wildcard, exact match, or dynamic segment match (`:id`).

All three return **false** for a nil user, a user without a role, or an invalid
`permission_set_name` (graceful, fail-closed — no crash). The scope/page-matching logic mirrors
`HasPermission` and `CheckPagePermission` exactly; resource names come from
`Module.split() |> List.last()`.

### UI Usage Pattern

LiveView templates gate elements with the helpers: page-level links use
`can_access_page?(@current_user, path)` (e.g. the `/members` link and the admin
dropdown), resource-level buttons use `can?(@current_user, :create, Resource)`
(e.g. "New Member"), and per-record buttons use `can?(@current_user, action, record)`
(e.g. Edit/Delete in a member row, or the edit button on a show page). The navbar has
since been replaced by the sidebar (`lib/mv_web/components/layouts/sidebar.ex`).

---

## 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 uses a two-tier approach:
- **READ**: Bypass with `expr()` for list queries (auto_filter)
- **UPDATE**: HasPermission with `scope :own` (evaluates PermissionSets)

```elixir
policies do
  # SPECIAL CASE: Users can always READ their own account
  # Bypass needed for list queries (expr() triggers auto_filter in Ash)
  bypass action_type(:read) do
    description "Users can always read their own account"
    authorize_if expr(id == ^actor(:id))
  end
  
  # GENERAL: Check permissions from user's role
  # UPDATE uses scope :own from PermissionSets (all sets grant User.update :own)
  policy action_type([:read, :create, :update, :destroy]) do
    authorize_if Mv.Authorization.Checks.HasPermission
  end
end
```

**Why this works:**
- READ bypass handles list queries correctly (auto_filter)
- UPDATE is handled by HasPermission with `scope :own` from PermissionSets
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own`
- Even a user with `read_only` (read-only for member data) can update their own credentials

**Important:** UPDATE is NOT an immovable special case (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details.

### 1a. User Credentials: Why read_only Can Still Update

**Question:** If `read_only` means "read-only", why can users with this permission set still update their own credentials?

**Answer:** The `read_only` permission set refers to **member data**, NOT user credentials. All permission sets grant `User.update :own` to allow password changes and profile updates.

**Implementation Details:**

1. **UPDATE is controlled by PermissionSets**, not a hardcoded bypass
2. **All 4 permission sets** (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant:
   ```elixir
   %{resource: "User", action: :update, scope: :own, granted: true}
   ```
3. **HasPermission** evaluates `scope :own` for UPDATE operations (when a changeset with record is present)
4. **No special bypass** is needed for UPDATE - it works correctly via HasPermission

**Why This Design?**

- **Flexibility:** Permission sets can be modified to change UPDATE behavior
- **Consistency:** All permissions are centralized in PermissionSets
- **Clarity:** The name "read_only" refers to member data, not user credentials
- **Maintainability:** Easy to see what each role can do in PermissionSets module

**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional — UPDATE is controlled by PermissionSets, not hardcoded. Every set's `get_permissions/...` therefore carries both `%{resource: "User", action: :read, scope: :own}` and `%{... action: :update, scope: :own}`; the "read_only" label applies to member data (no `Member :update`), not credentials.

### 2. Linked Member Email Editing

**Requirement:** For a member linked to a user account (has a linked user), only administrators **or the linked user themselves** can change the email. This prevents breaking the Member↔User email synchronization while still letting a user update their own email.

**Implementation:** The `Mv.Membership.Member.Validations.EmailChangePermission` module (registered as `validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]`) runs **after** the policy check (so a `normal_user` may update the member but is still blocked on the email field). It only acts when the email is changing: if the member has no linked user it allows the change; otherwise it allows the change when the actor is admin (`Mv.Authorization.Actor.admin?/1`, which also treats the system actor as admin) **or** owns the linked member (`actor.member_id == member.id`), and otherwise returns `{:error, "Only administrators or the linked user can change the email for members linked to users"}`. A missing actor is not allowed.

### 3. System Role Protection

**Requirement:** The "Mitglied" role cannot be deleted (it's the default role for all users).

**Implementation:** The `Role` resource has an `is_system_role` boolean (default false); a destroy validation returns `{:error, "Cannot delete system role. ..."}` when `role.is_system_role` is true. Seeds set `is_system_role: true` only on "Mitglied". The UI also hides the delete button: `can?(@current_user, :destroy, role) and not role.is_system_role`.

### 4. User Without Role (Edge Case)

**Requirement:** Users without a role are denied all access (except logout).

**Implementation:** Seeds assign "Mitglied" to all users where `role_id` is nil. At runtime every check handles a missing role gracefully — `HasPermission` returns `{:error, :no_role}` (and the UI helpers/plug return false) rather than crashing.

**Result:** A user with no role sees an empty UI, cannot access pages, and is 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:** Prevented up front by a `Role` attribute validation that rejects any value not in `PermissionSets.all_permission_sets/0` (`"Invalid permission set name. Must be one of: ..."`). At runtime, every lookup goes through `permission_set_name_to_atom/1`, which rescues the `ArgumentError` from `String.to_existing_atom/1` (see PermissionSets above), so an invalid name yields `{:error, :invalid_permission_set}`.

**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)

**Enforcement:**

- **User side:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only.

### Approach: One Pair of Actions Plus an Admin-Only `:user` Argument

Linking is **not** modelled as separate per-operation actions. The `Mv.Membership.Member`
resource (`lib/membership/member.ex`) exposes the actions `create_member`, `update_member`,
`set_vereinfacht_contact_id`, `search`, and `available_for_linking` (plus the default
`:read`/`:destroy`). Linking and unlinking happen through the optional **`:user` argument** on
`create_member` / `update_member`, not through dedicated `link_*`/`unlink_*` actions. (`user_id`
is deliberately **not** in the accept list, so the foreign key cannot be set directly.)

### Implementation

The user–member link is governed by two facts about `create_member` / `update_member`:

- The `:user` argument drives the relationship via `manage_relationship(:user, ...)` with
  `on_lookup: :relate`, `on_no_match: :error`, `on_match: :error`, and **`on_missing: :ignore`**.
  Because of `on_missing: :ignore`, **omitting** `:user` leaves the link unchanged (no "unlink by
  omission"); unlink is explicit (`user: nil`/`user: %{}`), handled on update via the
  `UnrelateUserWhenArgumentNil` change.
- Whether the `:user` argument may be used at all is gated by the policy check
  `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` (`forbid_if` before
  `authorize_if HasPermission` on `action_type([:create, :update])`). It forbids the action for a
  non-admin whenever the `:user` argument **key is present** (any value), so only admins may set or
  change the link. Non-admins can still create/update members as long as they do not pass `:user`.

Self-service ("a user creates a member and is linked to it") is handled on the **User** side, not
by a special Member action: the admin-only `update_user` action takes a `:member` argument for
link/unlink (see Enforcement above), and the UI gates the linking controls on admin status.

### Why This Design?

Keeping the link on a single `:user` argument (rather than a fan-out of `link_*`/`unlink_*`
actions) means there is exactly one create and one update path to reason about, the
admin-only rule lives in one reusable policy check (`ForbidMemberUserLinkUnlessAdmin`) instead of
being duplicated per action, and `user_id` can never be mass-assigned because it is not accepted —
only the argument-driven relationship management can change it.

---

## 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 an Ash calculation that takes `allowed_fields` from PermissionSets and
`Map.take/2`s each record to those fields. **Write protection** via an update validation that
diffs `Map.keys(changeset.attributes)` against the allowed write fields and returns
`{:error, "You do not have permission to modify: ..."}` for any forbidden field.

**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** (shipped 2026-01-08, PR #346, closes #345)
- Hardcoded PermissionSets module
- `HasPermission` check reads from module
- Role table with `permission_set_name` string
- Zero DB queries for permission checks

**What's NOT in MVP (deferred to Phase 3):**
- `PermissionSetResource` database table
- `PermissionSetPage` database table
- ETS Permission Cache
- Database-backed dynamic permissions / runtime permission editing

**MVP DB migration & rollback.** Issue #1 adds a single migration: create the `roles` table (`name` unique, `permission_set_name`, `is_system_role`, timestamps; indexes on `name` and `permission_set_name`) and add nullable `users.role_id` FK (`ON DELETE RESTRICT`) with its index. The migration is additive only — no existing table is modified destructively. The 5 roles are created by `priv/repo/seeds_bootstrap.exs`, and the `assign_mitglied_role_to_existing_users` migration assigns "Mitglied" to users without a role.

Rollback options, in order of escalation:
1. **DB rollback:** the `down` migration drops the `users.role_id` index, removes the `role_id` column, and drops the `roles` table — `mix ecto.rollback --step 1`. Existing tables are untouched.
2. **Code rollback:** revert the commit and redeploy the previous version.

**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

Sequence (~3-4 weeks): create the three permission tables + indexes; seed them from
`PermissionSets.get_permissions/1`; add a `HasResourcePermission` check that queries the DB
(same logic as `HasPermission`, different data source) backed by the ETS cache with
invalidation on update; swap `HasPermission` → `HasResourcePermission` in all resources and
point the UI helper + page plug at the DB/cache; integration + performance/load test; deploy
behind the feature flag (run both systems in parallel to compare) then gradually to production;
finally remove the old `HasPermission` check and `PermissionSets` module.

---

## 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: persist authorization failures (user id, resource, action, outcome,
reason, IP, timestamp) to an `AuditLog` resource — for tracking suspicious attempts, GDPR access
logs, and production debugging. Currently failures are only `Logger`-logged.

---

## Appendix

### Glossary

- **Permission Set:** Named collection of permissions (e.g., "admin", "read_only")
- **Role:** Database entity linking users to a permission set; **system role** cannot be deleted (`is_system_role=true`)
- **Scope:** Range of records a permission applies to (`:own`, `:linked`, `:all`)
- **Actor:** Currently authenticated user in Ash context
- **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.CustomFieldValue` | "CustomFieldValue" |
| `Mv.Membership.CustomField` | "CustomField" |
| `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 or linked user may edit | `Member.Validations.EmailChangePermission` |
| Own credentials | Always accessible | Special policy before general check |

---

## Authorization Bootstrap Patterns

This section clarifies three different mechanisms for bypassing standard authorization, their purposes, and when to use each.

### Overview

The codebase uses two authorization bypass mechanisms:

1. **system_actor** - Admin user for systemic operations
2. **authorize?: false** - Bootstrap bypass for circular dependencies

**Both are necessary and serve different purposes.**

**Note:** The NoActor bypass has been removed to prevent masking authorization bugs in tests. All tests now explicitly use `system_actor` for authorization.

### 1. System Actor

**Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions.

**Implementation:**
```elixir
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# => %User{email: "system@mila.local", role: %{permission_set_name: "admin"}}
```

**Security:**
- No password (hashed_password = nil) → cannot login
- No OIDC ID (oidc_id = nil) → cannot authenticate
- Cached in Agent for performance
- Created automatically in test environment if missing

**Use Cases:**
- **Email synchronization** (User ↔ Member email sync)
- **Email uniqueness validation** (cross-resource checks)
- **Cycle generation** (mandatory side effect)
- **OIDC account linking** (user not yet logged in)
- **Cross-resource validations** (must work regardless of actor)

**Example:**
```elixir
def get_linked_member(%{member_id: id}) do
  system_actor = SystemActor.get_system_actor()
  opts = Helpers.ash_actor_opts(system_actor)
  
  # Email sync must work regardless of user permissions
  Ash.get(Mv.Membership.Member, id, opts)
end
```

**Why not `authorize?: false`?**
- System actor is explicit (clear intent: "systemic operation")
- Policies are evaluated (with admin rights)
- Audit trail (actor.email = "system@mila.local")
- Consistent authorization flow
- Testable

### 2. authorize?: false

**Purpose:** Skip policies for bootstrap scenarios with circular dependencies.

**Use Cases:**

**1. Seeds** - No admin exists yet to use as actor:
```elixir
# priv/repo/seeds.exs
Accounts.create_user!(%{email: admin_email},
  authorize?: false  # Bootstrap: no admin exists yet
)
```

**2. SystemActor Bootstrap** - Chicken-and-egg problem:
```elixir
# lib/mv/helpers/system_actor.ex
defp find_user_by_email(email) do
  # Need to find system actor, but loading requires system actor!
  Mv.Accounts.User
  |> Ash.Query.filter(email == ^email)
  |> Ash.read_one(authorize?: false)  # Bootstrap only
end
```

**3. Actor.ensure_loaded** - Circular dependency:
```elixir
# lib/mv/authorization/actor.ex
defp load_role(actor) do
  # Actor needs role for authorization,
  # but loading role requires authorization!
  Ash.load(actor, :role, authorize?: false)  # Bootstrap only
end
```

**4. assign_default_role** - User creation:
```elixir
# User doesn't have actor during creation
Mv.Authorization.Role
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false)  # Bootstrap only
```

**Security:**
- Very powerful - skips ALL policies
- Use sparingly and document every usage
- Only for bootstrap scenarios
- All current usages are legitimate

### Comparison

| Aspect | system_actor | authorize?: false |
|--------|--------------|-------------------|
| **Environment** | All | All |
| **Actor** | Admin user | nil |
| **Policies** | Evaluated | Skipped |
| **Audit Trail** | Yes (system@mila.local) | No |
| **Use Case** | Systemic operations, test fixtures | Bootstrap |
| **Explicit?** | Function call | Query option |

### Decision Guide

**Use system_actor when:**
- ✅ Systemic operation must always succeed
- ✅ Email synchronization
- ✅ Cycle generation
- ✅ Cross-resource validations
- ✅ OIDC flows (user not logged in)

**Use authorize?: false when:**
- ✅ Bootstrap scenario (seeds)
- ✅ Circular dependency (SystemActor bootstrap, Actor.ensure_loaded)
- ⚠️ Document with comment explaining why

**DON'T:**
- ❌ Use `authorize?: false` for user-initiated actions
- ❌ Use `authorize?: false` when `system_actor` would work
- ❌ Skip actor in tests (always use system_actor)

### The Circular Dependency Problem

**SystemActor Bootstrap:**
```
SystemActor.get_system_actor()
  ↓ calls find_user_by_email()
  ↓ needs to query User
  ↓ User policies require actor
  ↓ but we're loading the actor!
  
Solution: authorize?: false for bootstrap query
```

**Actor.ensure_loaded:**
```
Authorization check (HasPermission)
  ↓ needs actor.role.permission_set_name
  ↓ but role is %Ash.NotLoaded{}
  ↓ load role with Ash.load(actor, :role)
  ↓ but loading requires authorization
  ↓ which needs actor.role!
  
Solution: authorize?: false for role load
```

**Why this is safe:**
- Actor is loading their OWN data (role relationship)
- Actor already passed authentication boundary
- Role contains no sensitive data (just permission_set reference)
- Alternative (denormalize permission_set_name) adds complexity

### Examples

**Good - system_actor for systemic operation:**
```elixir
defp check_if_email_used(email) do
  system_actor = SystemActor.get_system_actor()
  opts = Helpers.ash_actor_opts(system_actor)
  
  # Validation must work regardless of current actor
  Ash.read(User, opts)
end
```

**Good - authorize?: false for bootstrap:**
```elixir
# Seeds - no admin exists yet
Accounts.create_user!(%{email: admin_email}, authorize?: false)
```

**Bad - authorize?: false for user action:**
```elixir
# WRONG: Bypasses all policies for user-initiated action
def delete_member(member) do
  Ash.destroy(member, authorize?: false)  # ❌ Don't do this!
end

# CORRECT: Use actor
def delete_member(member, actor) do
  Ash.destroy(member, actor: actor)  # ✅ Policies enforced
end
```

---

**Document Version:** 2.0 (Clean Rewrite)  
**Last Updated:** 2026-01-23  
**Implementation Status:** ✅ Complete (2026-01-08)  
**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

**Changes from V2.0:**
- Added "Authorization Bootstrap Patterns" section explaining system_actor and authorize?: false
- Removed NoActor bypass (all tests now use system_actor for explicit authorization)

---

**End of Architecture Document**

