diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md new file mode 100644 index 0000000..fa45d86 --- /dev/null +++ b/docs/roles-and-permissions-architecture.md @@ -0,0 +1,2502 @@ +# Roles and Permissions Architecture - Technical Specification + +**Version:** 2.0 (Clean Rewrite) +**Date:** 2025-01-13 +**Status:** Ready for Implementation +**Related Documents:** +- [Overview](./roles-and-permissions-overview.md) - High-level concepts for stakeholders +- [Implementation Plan](./roles-and-permissions-implementation-plan.md) - Step-by-step implementation guide + +--- + +## Table of Contents + +- [Overview](#overview) +- [Requirements Analysis](#requirements-analysis) +- [Selected Architecture](#selected-architecture) +- [Database Schema (MVP)](#database-schema-mvp) +- [Permission System Design (MVP)](#permission-system-design-mvp) +- [Resource Policies](#resource-policies) +- [Page Permission System](#page-permission-system) +- [UI-Level Authorization](#ui-level-authorization) +- [Special Cases](#special-cases) +- [User-Member Linking](#user-member-linking) +- [Future: Phase 2 - Field-Level Permissions](#future-phase-2---field-level-permissions) +- [Future: Phase 3 - Database-Backed Permissions](#future-phase-3---database-backed-permissions) +- [Migration Strategy](#migration-strategy) +- [Security Considerations](#security-considerations) +- [Appendix](#appendix) + +--- + +## Overview + +This document provides the complete technical specification for the **Roles and Permissions system** in the Mila membership management application. The system controls who can access what data and which actions they can perform. + +### Key Design Principles + +1. **Security First:** Authorization is enforced at multiple layers (database policies, page access, UI rendering) +2. **Performance:** MVP uses hardcoded permissions for < 1 microsecond checks +3. **Maintainability:** Clear separation between roles (data) and permissions (logic) +4. **Extensibility:** Clean migration path to database-backed permissions (Phase 3) +5. **User Experience:** Consistent authorization across backend and frontend +6. **Test-Driven:** All components fully tested with behavior-focused tests + +### Architecture Approach + +**MVP (Phase 1) - Hardcoded Permission Sets:** +- Permission logic in Elixir module (`Mv.Authorization.PermissionSets`) +- Role data in database (`roles` table) +- Roles reference permission sets by name (string) +- Zero database queries for permission checks +- Implementation time: 2-3 weeks + +**Future (Phase 2) - Field-Level Permissions:** +- Extend PermissionSets with field-level granularity +- Ash Calculations for read filtering +- Custom Validations for write protection +- No database schema changes + +**Future (Phase 3) - Database-Backed Permissions:** +- Move permission data to database tables +- Runtime permission configuration +- ETS cache for performance +- Migration from hardcoded module + +--- + +## Requirements Analysis + +### Core Requirements + +**1. Predefined Permission Sets** + +Four hardcoded permission sets that define access patterns: + +- **own_data** - User can only access their own data (default for members) +- **read_only** - Read access to all member data, no modifications +- **normal_user** - Create/Read/Update on members (no delete), full CRUD on custom fields +- **admin** - Unrestricted access including user/role management + +**2. Roles Stored in Database** + +Five predefined roles stored in the `roles` table: + +- **Mitglied** (Member) → uses "own_data" permission set +- **Vorstand** (Board) → uses "read_only" permission set +- **Kassenwart** (Treasurer) → uses "normal_user" permission set +- **Buchhaltung** (Accounting) → uses "read_only" permission set +- **Admin** → uses "admin" permission set + +**3. Resource-Level Permissions** + +Control CRUD operations on: +- User (credentials, profile) +- Member (member data) +- Property (custom field values) +- PropertyType (custom field definitions) +- Role (role management) + +**4. Page-Level Permissions** + +Control access to LiveView pages: +- Index pages (list views) +- Show pages (detail views) +- Form pages (create/edit) +- Admin pages + +**5. Granular Scopes** + +Three scope levels for permissions: +- **:own** - Only records where `record.id == user.id` (for User resource) +- **:linked** - Only records linked to user via relationships + - Member: `member.user_id == user.id` + - Property: `property.member.user_id == user.id` +- **:all** - All records, no filtering + +**6. Special Cases** + +- **Own Credentials:** Every user can always read/update their own credentials +- **Linked Member Email:** Only admins can edit email of member linked to user +- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag) +- **User-Member Linking:** Only admins can link/unlink users and members + +**7. UI Consistency** + +- UI elements (buttons, links) only shown if user has permission +- Page access controlled before LiveView mounts +- Consistent authorization logic between backend and frontend + +--- + +## Selected Architecture + +### Approach: Hardcoded Permission Sets with Database Roles + +**Core Concept:** + +``` +PermissionSets Module (hardcoded in code) + ↓ (referenced by permission_set_name) +Role (stored in DB: "Vorstand" → "read_only") + ↓ (assigned to user via role_id) +User (each user has one role) +``` + +**Why This Approach?** + +✅ **Fast Implementation:** 2-3 weeks vs. 4-5 weeks for DB-backed +✅ **Maximum Performance:** < 1 microsecond per check (pure function call) +✅ **Zero DB Overhead:** No permission queries, no joins, no cache needed +✅ **Git-Tracked Changes:** All permission changes in version control +✅ **Deterministic Testing:** No DB setup, purely functional tests +✅ **Clear Migration Path:** Well-defined Phase 3 for DB-backed permissions + +**Trade-offs:** + +⚠️ **Deployment Required:** Permission changes need code deployment +⚠️ **Four Fixed Sets:** Cannot add new permission sets without code change +✔️ **Acceptable for MVP:** Requirements specify 4 fixed sets, rare changes expected + +### System Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Authorization System │ +└─────────────────────────────────────────────────────────────┘ + +┌──────────────────┐ +│ LiveView │ +│ (UI Layer) │ +└────────┬─────────┘ + │ + │ 1. Page Access Check + ↓ +┌──────────────────────────────────┐ +│ CheckPagePermission Plug │ +│ - Reads PermissionSets module │ +│ - Matches page pattern │ +│ - Redirects if unauthorized │ +└────────┬─────────────────────────┘ + │ + │ 2. UI Element Check + ↓ +┌──────────────────────────────────┐ +│ MvWeb.Authorization │ +│ - can?/3 │ +│ - can_access_page?/2 │ +│ - Uses PermissionSets module │ +└────────┬─────────────────────────┘ + │ + │ 3. Resource Action + ↓ +┌──────────────────────────────────┐ +│ Ash Resource (Member, User...) │ +│ - Policies block │ +└────────┬─────────────────────────┘ + │ + │ 4. Policy Evaluation + ↓ +┌──────────────────────────────────┐ +│ HasPermission Policy Check │ +│ - Reads actor.role │ +│ - Calls PermissionSets.get_permissions/1 │ +│ - Applies scope filter │ +└────────┬─────────────────────────┘ + │ + │ 5. Permission Lookup + ↓ +┌──────────────────────────────────┐ +│ PermissionSets Module │ +│ (Hardcoded in code) │ +│ - get_permissions/1 │ +│ - Returns {resources, pages} │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ Database │ +│ - roles table │ +│ - users.role_id → roles.id │ +└──────────────────────────────────┘ +``` + +**Authorization Flow:** + +1. **Page Request:** Plug checks if user can access page +2. **UI Rendering:** Helper checks which buttons/links to show +3. **User Action:** Ash receives action request (create, read, update, destroy) +4. **Policy Check:** `HasPermission` evaluates permission +5. **Permission Lookup:** Reads from `PermissionSets` module (in-memory) +6. **Scope Application:** Filters query based on scope (:own, :linked, :all) +7. **Result:** Action succeeds or fails with Forbidden error + +--- + +## Database Schema (MVP) + +### Overview + +The MVP requires **only ONE new table**: `roles` + +- ✅ Stores role definitions (name, description, permission_set_name) +- ✅ Links to users via foreign key +- ❌ NO permission tables (permissions are hardcoded) + +### Entity Relationship Diagram + +``` +┌─────────────────────────────────┐ +│ users │ +├─────────────────────────────────┤ +│ id (PK, UUID) │ +│ email │ +│ hashed_password │ +│ role_id (FK → roles.id) ◄───┼──┐ +│ ... │ │ +└─────────────────────────────────┘ │ + │ + │ +┌─────────────────────────────────┐ │ +│ roles │ │ +├─────────────────────────────────┤ │ +│ id (PK, UUID) │──┘ +│ name (unique) │ +│ description │ +│ permission_set_name (String) │───┐ +│ is_system_role (Boolean) │ │ +│ inserted_at │ │ +│ updated_at │ │ +└─────────────────────────────────┘ │ + │ + │ References one of: +┌─────────────────────────────────┐ │ - "own_data" +│ PermissionSets Module │◄──┘ - "read_only" +│ (Hardcoded in Code) │ - "normal_user" +├─────────────────────────────────┤ - "admin" +│ get_permissions(:own_data) │ +│ get_permissions(:read_only) │ +│ get_permissions(:normal_user) │ +│ get_permissions(:admin) │ +└─────────────────────────────────┘ +``` + +### Table Definitions + +#### roles + +Stores role definitions that reference permission sets by name. + +```sql +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + permission_set_name VARCHAR(50) NOT NULL, + is_system_role BOOLEAN NOT NULL DEFAULT false, + inserted_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT check_valid_permission_set + CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin')) +); + +CREATE UNIQUE INDEX roles_name_index ON roles (name); +CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name); +``` + +**Fields:** +- `name` - Display name (e.g., "Vorstand", "Admin") +- `description` - Human-readable description +- `permission_set_name` - References hardcoded permission set +- `is_system_role` - If true, role cannot be deleted (protects "Mitglied") + +**Constraints:** +- `name` must be unique +- `permission_set_name` must be one of 4 valid values +- System roles cannot be deleted (enforced in Ash resource) + +#### users (modified) + +Add foreign key to roles table. + +```sql +ALTER TABLE users + ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; + +CREATE INDEX users_role_id_index ON users (role_id); +``` + +**ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it. + +### Seed Data + +Five predefined roles created during initial setup: + +```elixir +# priv/repo/seeds/authorization_seeds.exs + +roles = [ + %{ + name: "Mitglied", + description: "Default member role with access to own data only", + permission_set_name: "own_data", + is_system_role: true # Cannot be deleted! + }, + %{ + name: "Vorstand", + description: "Board member with read access to all member data", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Kassenwart", + description: "Treasurer with full member and payment management", + permission_set_name: "normal_user", + is_system_role: false + }, + %{ + name: "Buchhaltung", + description: "Accounting with read-only access for auditing", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Admin", + description: "Administrator with unrestricted access", + permission_set_name: "admin", + is_system_role: false + } +] + +# Create roles with idempotent logic +Enum.each(roles, fn role_data -> + case Ash.get(Mv.Authorization.Role, name: role_data.name) do + {:ok, existing_role} -> + # Update if exists + Ash.update!(existing_role, role_data) + {:error, _} -> + # Create if not exists + Ash.create!(Mv.Authorization.Role, role_data) + end +end) + +# Assign "Mitglied" role to users without role +mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied") +users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id))) + +Enum.each(users_without_role, fn user -> + Ash.update!(user, %{role_id: mitglied_role.id}) +end) +``` + +--- + +## Permission System Design (MVP) + +### PermissionSets Module + +**Location:** `lib/mv/authorization/permission_sets.ex` + +This module is the **single source of truth** for all permissions in the MVP. It defines what each permission set can do. + +#### Module Structure + +```elixir +defmodule Mv.Authorization.PermissionSets do + @moduledoc """ + Defines the four hardcoded permission sets for the application. + + Each permission set specifies: + - Resource permissions (what CRUD operations on which resources) + - Page permissions (which LiveView pages can be accessed) + - Scopes (own, linked, all) + + ## Permission Sets + + 1. **own_data** - Default for "Mitglied" role + - Can only access own user data and linked member/properties + - Cannot create new members or manage system + + 2. **read_only** - For "Vorstand" and "Buchhaltung" roles + - Can read all member data + - Cannot create, update, or delete + + 3. **normal_user** - For "Kassenwart" role + - Create/Read/Update members (no delete), full CRUD on properties + - Cannot manage property types or users + + 4. **admin** - For "Admin" role + - Unrestricted access to all resources + - Can manage users, roles, property types + + ## Usage + + # Get permissions for a role's permission set + permissions = PermissionSets.get_permissions(:admin) + + # Check if a permission set name is valid + PermissionSets.valid_permission_set?("read_only") # => true + + # Convert string to atom safely + {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data") + + ## Performance + + All functions are pure and compile-time. Permission lookups are < 1 microsecond. + """ + + @type scope :: :own | :linked | :all + @type action :: :read | :create | :update | :destroy + + @type resource_permission :: %{ + resource: String.t(), + action: action(), + scope: scope(), + granted: boolean() + } + + @type permission_set :: %{ + resources: [resource_permission()], + pages: [String.t()] + } + + @doc """ + Returns the list of all valid permission set names. + + ## Examples + + iex> PermissionSets.all_permission_sets() + [:own_data, :read_only, :normal_user, :admin] + """ + @spec all_permission_sets() :: [atom()] + def all_permission_sets do + [:own_data, :read_only, :normal_user, :admin] + end + + @doc """ + Returns permissions for the given permission set. + + ## Examples + + iex> permissions = PermissionSets.get_permissions(:admin) + iex> Enum.any?(permissions.resources, fn p -> + ...> p.resource == "User" and p.action == :destroy + ...> end) + true + + iex> PermissionSets.get_permissions(:invalid) + ** (FunctionClauseError) no function clause matching + """ + @spec get_permissions(atom()) :: permission_set() + + def get_permissions(:own_data) do + %{ + resources: [ + # User: Can always read/update own credentials + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Can read/update linked member + %{resource: "Member", action: :read, scope: :linked, granted: true}, + %{resource: "Member", action: :update, scope: :linked, granted: true}, + + # Property: Can read/update properties of linked member + %{resource: "Property", action: :read, scope: :linked, granted: true}, + %{resource: "Property", action: :update, scope: :linked, granted: true}, + + # PropertyType: Can read all (needed for forms) + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", # Home page + "/profile", # Own profile + "/members/:id" # Linked member detail (filtered by policy) + ] + } + end + + def get_permissions(:read_only) do + %{ + resources: [ + # User: Can read/update own credentials only + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Can read all members, no modifications + %{resource: "Member", action: :read, scope: :all, granted: true}, + + # Property: Can read all properties + %{resource: "Property", action: :read, scope: :all, granted: true}, + + # PropertyType: Can read all + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", + "/members", # Member list + "/members/:id", # Member detail + "/properties", # Property overview + "/profile" # Own profile + ] + } + end + + def get_permissions(:normal_user) do + %{ + resources: [ + # User: Can read/update own credentials only + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Full CRUD + %{resource: "Member", action: :read, scope: :all, granted: true}, + %{resource: "Member", action: :create, scope: :all, granted: true}, + %{resource: "Member", action: :update, scope: :all, granted: true}, + # Note: destroy intentionally omitted for safety + + # Property: Full CRUD + %{resource: "Property", action: :read, scope: :all, granted: true}, + %{resource: "Property", action: :create, scope: :all, granted: true}, + %{resource: "Property", action: :update, scope: :all, granted: true}, + %{resource: "Property", action: :destroy, scope: :all, granted: true}, + + # PropertyType: Read only (admin manages definitions) + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", + "/members", + "/members/new", # Create member + "/members/:id", + "/members/:id/edit", # Edit member + "/properties", + "/properties/new", + "/properties/:id/edit", + "/profile" + ] + } + end + + def get_permissions(:admin) do + %{ + resources: [ + # User: Full management including other users + %{resource: "User", action: :read, scope: :all, granted: true}, + %{resource: "User", action: :create, scope: :all, granted: true}, + %{resource: "User", action: :update, scope: :all, granted: true}, + %{resource: "User", action: :destroy, scope: :all, granted: true}, + + # Member: Full CRUD + %{resource: "Member", action: :read, scope: :all, granted: true}, + %{resource: "Member", action: :create, scope: :all, granted: true}, + %{resource: "Member", action: :update, scope: :all, granted: true}, + %{resource: "Member", action: :destroy, scope: :all, granted: true}, + + # Property: Full CRUD + %{resource: "Property", action: :read, scope: :all, granted: true}, + %{resource: "Property", action: :create, scope: :all, granted: true}, + %{resource: "Property", action: :update, scope: :all, granted: true}, + %{resource: "Property", action: :destroy, scope: :all, granted: true}, + + # PropertyType: Full CRUD (admin manages custom field definitions) + %{resource: "PropertyType", action: :read, scope: :all, granted: true}, + %{resource: "PropertyType", action: :create, scope: :all, granted: true}, + %{resource: "PropertyType", action: :update, scope: :all, granted: true}, + %{resource: "PropertyType", action: :destroy, scope: :all, granted: true}, + + # Role: Full CRUD (admin manages roles) + %{resource: "Role", action: :read, scope: :all, granted: true}, + %{resource: "Role", action: :create, scope: :all, granted: true}, + %{resource: "Role", action: :update, scope: :all, granted: true}, + %{resource: "Role", action: :destroy, scope: :all, granted: true} + ], + pages: [ + "*" # Wildcard: Admin can access all pages + ] + } + end + + @doc """ + Checks if a permission set name (string or atom) is valid. + + ## Examples + + iex> PermissionSets.valid_permission_set?("admin") + true + + iex> PermissionSets.valid_permission_set?(:read_only) + true + + iex> PermissionSets.valid_permission_set?("invalid") + false + """ + @spec valid_permission_set?(String.t() | atom()) :: boolean() + def valid_permission_set?(name) when is_binary(name) do + case permission_set_name_to_atom(name) do + {:ok, _atom} -> true + {:error, _} -> false + end + end + + def valid_permission_set?(name) when is_atom(name) do + name in all_permission_sets() + end + + @doc """ + Converts a permission set name string to atom safely. + + ## Examples + + iex> PermissionSets.permission_set_name_to_atom("admin") + {:ok, :admin} + + iex> PermissionSets.permission_set_name_to_atom("invalid") + {:error, :invalid_permission_set} + """ + @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set} + def permission_set_name_to_atom(name) when is_binary(name) do + atom = String.to_existing_atom(name) + if valid_permission_set?(atom) do + {:ok, atom} + else + {:error, :invalid_permission_set} + end + rescue + ArgumentError -> {:error, :invalid_permission_set} + end +end +``` + +#### Permission Matrix + +Quick reference table showing what each permission set allows: + +| Resource | own_data | read_only | normal_user | admin | +|----------|----------|-----------|-------------|-------| +| **User** (own) | R, U | R, U | R, U | R, U | +| **User** (all) | - | - | - | R, C, U, D | +| **Member** (linked) | R, U | - | - | - | +| **Member** (all) | - | R | R, C, U | R, C, U, D | +| **Property** (linked) | R, U | - | - | - | +| **Property** (all) | - | R | R, C, U, D | R, C, U, D | +| **PropertyType** (all) | R | R | R | R, C, U, D | +| **Role** (all) | - | - | - | R, C, U, D | + +**Legend:** R=Read, C=Create, U=Update, D=Destroy + +### HasPermission Policy Check + +**Location:** `lib/mv/authorization/checks/has_permission.ex` + +This is a custom Ash Policy Check that evaluates permissions from the `PermissionSets` module. + +```elixir +defmodule Mv.Authorization.Checks.HasPermission do + @moduledoc """ + Custom Ash Policy Check that evaluates permissions from the PermissionSets module. + + This check: + 1. Reads the actor's role and permission_set_name + 2. Looks up permissions from PermissionSets.get_permissions/1 + 3. Finds matching permission for current resource + action + 4. Applies scope filter (:own, :linked, :all) + + ## Usage in Ash Resource + + policies do + policy action_type(:read) do + authorize_if Mv.Authorization.Checks.HasPermission + end + end + + ## Scope Behavior + + - **:all** - Authorizes without filtering (returns all records) + - **:own** - Filters to records where record.id == actor.id + - **:linked** - Filters based on resource type: + - Member: member.user_id == actor.id + - Property: property.member.user_id == actor.id (traverses relationship!) + + ## Error Handling + + Returns `{:error, reason}` for: + - Missing actor + - Actor without role + - Invalid permission_set_name + - No matching permission found + + All errors result in Forbidden (policy fails). + """ + + use Ash.Policy.Check + require Ash.Query + import Ash.Expr + alias Mv.Authorization.PermissionSets + + @impl true + def describe(_opts) do + "checks if actor has permission via their role's permission set" + end + + @impl true + def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do + with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom), + resource_name <- get_resource_name(resource) do + check_permission(permissions.resources, resource_name, action, actor, resource_name) + else + %{role: nil} -> + log_auth_failure(actor, resource, action, "no role assigned") + {:error, :no_role} + + %{role: %{permission_set_name: nil}} -> + log_auth_failure(actor, resource, action, "role has no permission_set_name") + {:error, :no_permission_set} + + {:error, :invalid_permission_set} = error -> + log_auth_failure(actor, resource, action, "invalid permission_set_name") + error + + _ -> + log_auth_failure(actor, resource, action, "no actor or missing data") + {:error, :no_permission} + end + end + + # Extract resource name from module (e.g., Mv.Membership.Member -> "Member") + defp get_resource_name(resource) when is_atom(resource) do + resource |> Module.split() |> List.last() + end + + # Find matching permission and apply scope + defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do + case Enum.find(resource_perms, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) do + nil -> + {:error, :no_permission} + + perm -> + apply_scope(perm.scope, actor, resource_name) + end + end + + # Scope: all - No filtering, access to all records + defp apply_scope(:all, _actor, _resource) do + :authorized + end + + # Scope: own - Filter to records where record.id == actor.id + # Used for User resource (users can access their own user record) + defp apply_scope(:own, actor, _resource) do + {:filter, expr(id == ^actor.id)} + end + + # Scope: linked - Filter based on user_id relationship (resource-specific!) + defp apply_scope(:linked, actor, resource_name) do + case resource_name do + "Member" -> + # Member.user_id == actor.id (direct relationship) + {:filter, expr(user_id == ^actor.id)} + + "Property" -> + # Property.member.user_id == actor.id (traverse through member!) + {:filter, expr(member.user_id == ^actor.id)} + + _ -> + # Fallback for other resources: try direct user_id + {:filter, expr(user_id == ^actor.id)} + end + end + + # Log authorization failures for debugging + defp log_auth_failure(actor, resource, action, reason) do + require Logger + + actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" + resource_name = get_resource_name(resource) + + Logger.debug(""" + Authorization failed: + Actor: #{actor_id} + Resource: #{resource_name} + Action: #{action} + Reason: #{reason} + """) + end +end +``` + +**Key Design Decisions:** + +1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id` +2. **Error Handling:** All errors log for debugging but return generic forbidden to user +3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings +4. **Pure Function:** No side effects, deterministic, easily testable + +--- + +## Resource Policies + +Each Ash resource defines policies that use the `HasPermission` check. This section documents the policy structure for each resource. + +### General Policy Pattern + +**All resources follow this pattern:** + +```elixir +policies do + # 1. Special cases first (most specific) + policy action_type(:read) do + authorize_if expr(condition_for_special_case) + end + + # 2. General authorization (uses PermissionSets) + policy action_type([:read, :create, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission + end + + # 3. Default: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end +end +``` + +**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins. + +### User Resource Policies + +**Location:** `lib/mv/accounts/user.ex` + +**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role. + +```elixir +defmodule Mv.Accounts.User do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can always access their own account + # This takes precedence over permission checks + policy action_type([:read, :update]) do + description "Users can always read and update their own account" + authorize_if expr(id == ^actor(:id)) + end + + # GENERAL: Other operations require permission + # (e.g., admin reading/updating other users, admin destroying users) + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid if no policy matched + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | +| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | +| Read others | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update others | ❌ | ❌ | ❌ | ❌ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Member Resource Policies + +**Location:** `lib/mv/membership/member.ex` + +**Special Case:** Users can always access their linked member (where `member.user_id == user.id`). + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can always access their linked member + policy action_type([:read, :update]) do + description "Users can access member linked to their account" + authorize_if expr(user_id == ^actor(:id)) + end + + # GENERAL: Check permissions from role + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # Custom validation for email editing (see Special Cases section) + validations do + validate changing(:email), on: :update do + validate &validate_linked_member_email_change/2 + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special)* | ❌ | ✅* | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +*Email editing has additional validation (see Special Cases) + +### Property Resource Policies + +**Location:** `lib/mv/membership/property.ex` + +**Special Case:** Users can access properties of their linked member. + +```elixir +defmodule Mv.Membership.Property do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can access properties of their linked member + # Note: This traverses the member relationship! + policy action_type([:read, :update]) do + description "Users can access properties of their linked member" + authorize_if expr(member.user_id == ^actor(:id)) + end + + # GENERAL: Check permissions from role + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ | + +### PropertyType Resource Policies + +**Location:** `lib/mv/membership/property_type.ex` + +**No Special Cases:** All users can read, only admin can write. + +```elixir +defmodule Mv.Membership.PropertyType do + use Ash.Resource, ... + + policies do + # All authenticated users can read property types (needed for forms) + # Write operations are admin-only + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read | ✅ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Role Resource Policies + +**Location:** `lib/mv/authorization/role.ex` + +**Special Protection:** System roles cannot be deleted. + +```elixir +defmodule Mv.Authorization.Role do + use Ash.Resource, ... + + policies do + # Only admin can manage roles + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # Prevent deletion of system roles + validations do + validate action(:destroy) do + validate fn _changeset, %{data: role} -> + if role.is_system_role do + {:error, "Cannot delete system role"} + else + :ok + end + end + end + end + + # Validate permission_set_name + validations do + validate attribute(:permission_set_name) do + validate fn _changeset, value -> + if PermissionSets.valid_permission_set?(value) do + :ok + else + {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} + end + end + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read | ❌ | ❌ | ❌ | ❌ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ | + +*Cannot destroy if `is_system_role=true` + +--- + +## Page Permission System + +Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug. + +### CheckPagePermission Plug + +**Location:** `lib/mv_web/plugs/check_page_permission.ex` + +This plug runs in the router pipeline and checks if the current user has permission to access the requested page. + +```elixir +defmodule MvWeb.Plugs.CheckPagePermission do + @moduledoc """ + Plug that checks if current user has permission to access the current page. + + ## How It Works + + 1. Extracts page path from conn (route template like "/members/:id") + 2. Gets current user from conn.assigns + 3. Gets user's permission_set_name from role + 4. Calls PermissionSets.get_permissions/1 to get allowed pages + 5. Matches requested path against allowed patterns + 6. If unauthorized: redirects to "/" with flash error + + ## Pattern Matching + + - Exact match: "/members" == "/members" + - Dynamic routes: "/members/:id" matches "/members/123" + - Wildcard: "*" matches everything (admin) + + ## Usage in Router + + pipeline :require_page_permission do + plug MvWeb.Plugs.CheckPagePermission + end + + scope "/members", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/", MemberLive.Index + live "/:id", MemberLive.Show + end + """ + + import Plug.Conn + import Phoenix.Controller + alias Mv.Authorization.PermissionSets + require Logger + + def init(opts), do: opts + + def call(conn, _opts) do + user = conn.assigns[:current_user] + page_path = get_page_path(conn) + + if has_page_permission?(user, page_path) do + conn + else + log_page_access_denied(user, page_path) + + conn + |> put_flash(:error, "You don't have permission to access this page.") + |> redirect(to: "/") + |> halt() + end + end + + # Extract page path from conn (route template preferred, fallback to request_path) + defp get_page_path(conn) do + case conn.private[:phoenix_route] do + {_plug, _opts, _pipe, route_template, _meta} -> + route_template + + _ -> + conn.request_path + end + end + + # Check if user has permission for page + defp has_page_permission?(nil, _page_path) do + false + end + + defp has_page_permission?(user, page_path) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + page_matches?(permissions.pages, page_path) + else + _ -> false + end + end + + # Check if requested path matches any allowed pattern + defp page_matches?(allowed_pages, requested_path) do + Enum.any?(allowed_pages, fn pattern -> + cond do + # Wildcard: admin can access all pages + pattern == "*" -> + true + + # Exact match + pattern == requested_path -> + true + + # Dynamic route match (e.g., "/members/:id" matches "/members/123") + String.contains?(pattern, ":") -> + match_dynamic_route?(pattern, requested_path) + + # No match + true -> + false + end + end) + end + + # Match dynamic route pattern against actual path + defp match_dynamic_route?(pattern, path) do + pattern_segments = String.split(pattern, "/", trim: true) + path_segments = String.split(path, "/", trim: true) + + # Must have same number of segments + if length(pattern_segments) == length(path_segments) do + Enum.zip(pattern_segments, path_segments) + |> Enum.all?(fn {pattern_seg, path_seg} -> + # Dynamic segment (starts with :) matches anything + String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg + end) + else + false + end + end + + defp log_page_access_denied(user, page_path) do + user_id = if is_map(user), do: Map.get(user, :id), else: "nil" + role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil" + + Logger.info(""" + Page access denied: + User: #{user_id} + Role: #{role} + Page: #{page_path} + """) + end +end +``` + +### Router Integration + +Add plug to protected routes: + +```elixir +defmodule MvWeb.Router do + use MvWeb, :router + + pipeline :require_page_permission do + plug MvWeb.Plugs.CheckPagePermission + end + + # Public routes (no authentication) + scope "/", MvWeb do + pipe_through :browser + + live "/", PageController, :home + get "/login", AuthController, :new + post "/login", AuthController, :create + end + + # Protected routes (authentication + page permission) + scope "/members", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/", MemberLive.Index, :index + live "/new", MemberLive.Form, :new + live "/:id", MemberLive.Show, :show + live "/:id/edit", MemberLive.Form, :edit + end + + # Admin routes + scope "/admin", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/roles", RoleLive.Index, :index + live "/roles/:id", RoleLive.Show, :show + end +end +``` + +### Page Permission Examples + +**Mitglied (own_data):** +- ✅ Can access: `/`, `/profile`, `/members/123` (if 123 is their linked member) +- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles` + +**Vorstand (read_only):** +- ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile` +- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` + +**Kassenwart (normal_user):** +- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile` +- ❌ Cannot access: `/admin/roles`, `/admin/property_types/new` + +**Admin:** +- ✅ Can access: `*` (all pages, including `/admin/roles`) + +--- + +## UI-Level Authorization + +UI-level authorization ensures that users only see buttons, links, and form fields they have permission to use. This provides a consistent user experience and prevents confusing "forbidden" errors. + +### MvWeb.Authorization Helper Module + +**Location:** `lib/mv_web/authorization.ex` + +This module provides helper functions for conditional rendering in LiveView templates. + +```elixir +defmodule MvWeb.Authorization do + @moduledoc """ + UI-level authorization helpers for LiveView templates. + + These functions check if the current user has permission to perform actions + or access pages. They use the same PermissionSets module as the backend policies, + ensuring UI and backend authorization are consistent. + + ## Usage in Templates + + + <%= if can?(@current_user, :create, Mv.Membership.Member) do %> + <.link patch={~p"/members/new"}>New Member + <% end %> + + + <%= if can?(@current_user, :update, @member) do %> + <.button>Edit + <% end %> + + + <%= if can_access_page?(@current_user, "/admin/roles") do %> + <.link navigate="/admin/roles">Manage Roles + <% end %> + + ## Performance + + All checks are pure function calls using the hardcoded PermissionSets module. + No database queries, < 1 microsecond per check. + """ + + alias Mv.Authorization.PermissionSets + + @doc """ + Checks if user has permission for an action on a resource (atom). + + ## Examples + + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can?(admin, :create, Mv.Membership.Member) + true + + iex> mitglied = %{role: %{permission_set_name: "own_data"}} + iex> can?(mitglied, :create, Mv.Membership.Member) + false + """ + @spec can?(map() | nil, atom(), atom()) :: boolean() + def can?(nil, _action, _resource), do: false + + def can?(user, action, resource) when is_atom(action) and is_atom(resource) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + resource_name = get_resource_name(resource) + + Enum.any?(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) + else + _ -> false + end + end + + @doc """ + Checks if user has permission for an action on a specific record (struct). + + Applies scope checking: + - :own - record.id == user.id + - :linked - record.user_id == user.id (or traverses relationships) + - :all - always true + + ## Examples + + iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}} + iex> member = %Member{id: "member-456", user_id: "user-123"} + iex> can?(user, :update, member) + true + + iex> other_member = %Member{id: "member-789", user_id: "other-user"} + iex> can?(user, :update, other_member) + false + """ + @spec can?(map() | nil, atom(), struct()) :: boolean() + def can?(nil, _action, _record), do: false + + def can?(user, action, %resource{} = record) when is_atom(action) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + resource_name = get_resource_name(resource) + + # Find matching permission + matching_perm = Enum.find(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) + + case matching_perm do + nil -> false + perm -> check_scope(perm.scope, user, record, resource_name) + end + else + _ -> false + end + end + + @doc """ + Checks if user can access a specific page. + + ## Examples + + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can_access_page?(admin, "/admin/roles") + true + + iex> mitglied = %{role: %{permission_set_name: "own_data"}} + iex> can_access_page?(mitglied, "/members") + false + """ + @spec can_access_page?(map() | nil, String.t()) :: boolean() + def can_access_page?(nil, _page_path), do: false + + def can_access_page?(user, page_path) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + page_matches?(permissions.pages, page_path) + else + _ -> false + end + end + + # Check if scope allows access to record + defp check_scope(:all, _user, _record, _resource_name), do: true + + defp check_scope(:own, user, record, _resource_name) do + record.id == user.id + end + + defp check_scope(:linked, user, record, resource_name) do + case resource_name do + "Member" -> + # Direct relationship: member.user_id + Map.get(record, :user_id) == user.id + + "Property" -> + # Need to traverse: property.member.user_id + # Note: In UI, property should have member preloaded + case Map.get(record, :member) do + %{user_id: member_user_id} -> member_user_id == user.id + _ -> false + end + + _ -> + # Fallback: check user_id + Map.get(record, :user_id) == user.id + end + end + + # Check if page path matches any allowed pattern + defp page_matches?(allowed_pages, requested_path) do + Enum.any?(allowed_pages, fn pattern -> + cond do + pattern == "*" -> true + pattern == requested_path -> true + String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path) + true -> false + end + end) + end + + # Match dynamic route pattern + defp match_pattern?(pattern, path) do + pattern_segments = String.split(pattern, "/", trim: true) + path_segments = String.split(path, "/", trim: true) + + if length(pattern_segments) == length(path_segments) do + Enum.zip(pattern_segments, path_segments) + |> Enum.all?(fn {pattern_seg, path_seg} -> + String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg + end) + else + false + end + end + + # Extract resource name from module + defp get_resource_name(resource) when is_atom(resource) do + resource |> Module.split() |> List.last() + end +end +``` + +### Import in mv_web.ex + +Make helpers available to all LiveViews: + +```elixir +defmodule MvWeb do + # ... + + def html_helpers do + quote do + # ... existing helpers ... + + # Authorization helpers + import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] + end + end + + # ... +end +``` + +### UI Examples + +**Navbar with conditional links:** + +```heex + + +``` + +**Index page with conditional "New" button:** + +```heex + + + + + + <%= for member <- @members do %> + + + + + <% end %> +
<%= member.name %> + + <%= if can?(@current_user, :update, member) do %> + <.link patch={~p"/members/#{member.id}/edit"}>Edit + <% end %> + + + <%= if can?(@current_user, :destroy, member) do %> + <.button phx-click="delete" phx-value-id={member.id}>Delete + <% end %> +
+``` + +**Show page with conditional edit button:** + +```heex + +
+

<%= @member.name %>

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