# Roles and Permissions - Implementation Plan (MVP)

**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
- [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/custom field values (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, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle)
- ✅ Page-level permissions via Phoenix Plug (including admin-only `/settings` and `/membership_fee_settings`)
- ✅ UI authorization helpers for conditional rendering
- ✅ Special case: Member email validation for linked users
- ✅ User role assignment: admin-only `role_id` in update_user; Last-Admin validation; role dropdown in User form when `can?(actor, :update, Role)`
- ✅ 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
  - CustomFieldValue: read/update :linked
  - CustomField: read :all
- Pages: `["/", "/profile", "/members/:id"]`

**2. read_only (Vorstand, Buchhaltung):**
- Resources:
  - User: read :own, update :own
  - Member: read :all
  - CustomFieldValue: read :all
  - CustomField: read :all
- Pages: `["/", "/members", "/members/:id", "/custom_field_values"]`

**3. normal_user (Kassenwart):**
- Resources:
  - User: read/update :own
  - Member: read/create/update :all (no destroy for safety)
  - CustomFieldValue: read/create/update/destroy :all
  - CustomField: read :all
- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/custom_field_values", "/custom_field_values/new", "/custom_field_values/:id/edit"]`

**4. admin:**
- Resources:
  - User: read/update/destroy :all
  - Member: read/create/update/destroy :all
  - CustomFieldValue: read/create/update/destroy :all
  - CustomField: 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/CustomFieldValue read/update with scope :linked
- `:read_only` allows Member/CustomFieldValue read with scope :all
- `:read_only` does NOT allow Member/CustomFieldValue create/update/destroy
- `:normal_user` allows Member/CustomFieldValue 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)}`
     - CustomFieldValue: `{: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 CustomFieldValue 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 CustomFieldValue where custom_field_value.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  
**Status:** ✅ **COMPLETED**

**Description:**

Add authorization policies to the User resource. Users can always read their own credentials (via bypass), and update their own credentials (via HasPermission with scope :own).

**Implementation Pattern:**

Following the same pattern as Member resource:
- **Bypass for READ** - Handles list queries (auto_filter)
- **HasPermission for UPDATE** - Handles updates with scope :own

**Tasks:**

1. ✅ Open `lib/accounts/user.ex`
2. ✅ Add `policies` block
3. ✅ Add AshAuthentication bypass (registration/login without actor)
4. ✅ ~~Add NoActor bypass (test environment only)~~ **REMOVED** - NoActor bypass was removed to prevent masking authorization bugs. All tests now use `system_actor`.
5. ✅ Add bypass for READ: Allow user to always read their own account
   ```elixir
   bypass action_type(:read) do
     description "Users can always read their own account"
     authorize_if expr(id == ^actor(:id))
   end
   ```
6. ✅ Add general policy: Check HasPermission for all actions (including UPDATE with scope :own)
7. ✅ Ensure :destroy is admin-only (via HasPermission)
8. ✅ Preload :role relationship for actor in tests

**Policy Order:**
1. ✅ AshAuthentication bypass (registration/login)
2. ✅ Bypass: User can READ own account (id == actor.id)
3. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all)
4. ✅ Default: Ash implicitly forbids (fail-closed)

**Note:** NoActor bypass was removed. All tests now use `system_actor` for authorization.

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

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

This ensures `scope :own` in PermissionSets is actually used (not redundant).

**Acceptance Criteria:**

- ✅ User can always read own credentials (via bypass)
- ✅ User can always update own credentials (via HasPermission with scope :own)
- ✅ Only admin can read/update other users (scope :all)
- ✅ Only admin can destroy users (scope :all)
- ✅ Policy order is correct (AshAuth → Bypass READ → HasPermission)
- ✅ Actor preloads :role relationship
- ✅ All tests pass (30/31 pass, 1 skipped)

**Test Results:**

**Test File:** `test/mv/accounts/user_policies_test.exs`
- ✅ 31 tests total: 30 passing, 1 skipped (AshAuthentication edge case)
- ✅ Tests for all 4 permission sets: own_data, read_only, normal_user, admin
- ✅ Tests for AshAuthentication bypass (registration/login)
- ✅ Tests use system_actor for authorization (NoActor bypass removed)
- ✅ Tests verify scope :own is used for UPDATE (not redundant)

---

#### Issue #9: CustomFieldValue 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 CustomFieldValue resource. CustomFieldValues are linked to members, which are linked to users.

**Tasks:**

1. Open `lib/mv/membership/custom_field_value.ex`
2. Add `policies` block
3. Add special policy: Allow user to read/update custom field values 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 CustomFieldValue 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 CustomFieldValues Tests (:own_data):**
- User can read custom field values of their linked member
- User can update custom field values of their linked member
- User cannot read custom field values of unlinked members
- Verify relationship traversal works (custom_field_value.member.user_id)

**Read-Only Tests:**
- User with :read_only can read all custom field values
- User with :read_only cannot create/update custom field values

**Normal User Tests:**
- User with :normal_user can CRUD custom field values

**Admin Tests:**
- Admin can perform all operations

**Test File:** `test/mv/membership/custom_field_value_policies_test.exs`

---

#### Issue #10: CustomField 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 CustomField resource. CustomFields are admin-managed, but readable by all.

**Tasks:**

1. Open `lib/mv/membership/custom_field.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 custom fields
- [ ] Only admin can create/update/destroy custom fields
- [ ] Policies tested

**Test Strategy (TDD):**

**Read Access (All Roles):**
- User with :own_data can read all custom fields
- User with :read_only can read all custom fields
- User with :normal_user can read all custom fields
- User with :admin can read all custom fields

**Write Access (Admin Only):**
- Non-admin cannot create custom field (Forbidden)
- Non-admin cannot update custom field (Forbidden)
- Non-admin cannot destroy custom field (Forbidden)
- Admin can create custom field
- Admin can update custom field
- Admin can destroy custom field

**Test File:** `test/mv/membership/custom_field_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 CustomFieldValue 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 CustomFieldValue of linked Member (custom_field_value.member.user_id == user.id)
- User cannot update CustomFieldValue of unlinked Member
- Scope checking is resource-specific (Member vs CustomFieldValue)

**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</.link>
   <% 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, CustomFieldValue, CustomField) 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. **CustomFieldValue LiveViews:**
   - Similar to Member (hide create/edit/delete based on permissions)

4. **CustomField 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", "Custom Field Values", "Custom Fields", "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/custom_field_value_live_authorization_test.exs`
- `test/mv_web/live/custom_field_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 custom fields (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  │             │
   │ CustomFieldValue │       │  CustomField   │             │
   │ 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
│   ├── custom_field_value_policies_test.exs                # Issue #9
│   └── custom_field_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
│       ├── custom_field_value_live_authorization_test.exs  # Issue #16
│       └── custom_field_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**

