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. + diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index 176edc8..ab8f104 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,9 +19,6 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) - **Read-only (Edit mode only):** - - slug - Auto-generated URL-friendly identifier (immutable) - ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -52,19 +49,6 @@ defmodule MvWeb.CustomFieldLive.Form do <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <%!-- Show slug in edit mode (read-only) --%> -
- -
- {@custom_field.slug} -
-

- {gettext("Auto-generated identifier (immutable)")} -

-
- <.input field={@form[:value_type]} type="select" diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index bbd8603..65a3ab3 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,7 +11,6 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information - - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -44,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do rows={@streams.custom_fields} row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} > - <:col :let={{_id, custom_field}} label="Slug">{custom_field.slug} - <:col :let={{_id, custom_field}} label="Name">{custom_field.name} <:col :let={{_id, custom_field}} label="Description">{custom_field.description} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 2b2ba65..239b844 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -50,7 +50,12 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} - <:item title="Slug">{@custom_field.slug} + <:item title="Slug"> + {@custom_field.slug} +

+ {gettext("Auto-generated identifier (immutable)")} +

+ <:item title="Name">{@custom_field.name} diff --git a/lib/mv_web/live/custom_field_value_live/form.ex b/lib/mv_web/live/custom_field_value_live/form.ex index 7df4c69..4a7b02d 100644 --- a/lib/mv_web/live/custom_field_value_live/form.ex +++ b/lib/mv_web/live/custom_field_value_live/form.ex @@ -39,7 +39,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do <.header> {@page_title} <:subtitle> - {gettext("Use this form to manage custom_field_value records in your database.")} + {gettext("Use this form to manage Custom Field Value records in your database.")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 2538e7f..b7f472d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -41,7 +41,7 @@ msgid "Delete" msgstr "Löschen" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:247 +#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" @@ -158,10 +158,10 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:80 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:230 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." @@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:124 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:125 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,10 +252,10 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:83 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:233 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" @@ -265,7 +265,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:76 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -285,7 +285,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:77 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -313,7 +313,7 @@ msgstr "Mitglied" msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:53 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -335,7 +335,7 @@ msgstr "Nicht gesetzt" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:209 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" @@ -356,7 +356,7 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:78 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" @@ -376,7 +376,7 @@ msgstr "Mitglied auswählen" msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex:231 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" @@ -401,7 +401,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}" msgid "Use this form to manage user records in your database." msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten." -#: lib/mv_web/live/user_live/form.ex:248 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -412,7 +412,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:71 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -429,7 +429,7 @@ msgstr "aufsteigend" msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex:247 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" @@ -620,7 +620,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:131 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -635,7 +635,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:81 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -645,32 +645,32 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:48 +#: lib/mv_web/live/custom_field_live/form.ex:45 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Use this form to manage custom_field_value records in your database." -msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/user_live/form.ex:209 -#, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." -msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Auto-generated identifier (immutable)" msgstr "Automatisch generierte Kennung (unveränderlich)" -#: lib/mv_web/live/user_live/form.ex:184 +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." + +#: lib/mv_web/live/user_live/form.ex:185 #, elixir-autogen, elixir-format msgid "Available members" msgstr "Verfügbare Mitglieder" @@ -680,7 +680,7 @@ msgstr "Verfügbare Mitglieder" msgid "Member will be unlinked when you save. Cannot select new member until saved." msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." -#: lib/mv_web/live/user_live/form.ex:222 +#: lib/mv_web/live/user_live/form.ex:226 #, elixir-autogen, elixir-format msgid "Save to confirm linking." msgstr "Speichern, um die Verknüpfung zu bestätigen." @@ -695,16 +695,11 @@ msgstr "Nach einem Mitglied zum Verknüpfen suchen..." msgid "Search for member to link" msgstr "Nach Mitglied zum Verknüpfen suchen" -#: lib/mv_web/live/user_live/form.ex:219 +#: lib/mv_web/live/user_live/form.ex:223 #, elixir-autogen, elixir-format msgid "Selected" msgstr "Ausgewählt" -#: lib/mv_web/live/custom_field_live/form.ex:58 -#, elixir-autogen, elixir-format -msgid "Slug" -msgstr "Slug" - #: lib/mv_web/live/user_live/form.ex:143 #, elixir-autogen, elixir-format msgid "Unlink Member" @@ -714,3 +709,8 @@ msgstr "Mitglied entverknüpfen" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" + +#~ #: lib/mv_web/live/custom_field_live/form.ex:58 +#~ #, elixir-autogen, elixir-format +#~ msgid "Slug" +#~ msgstr "Slug" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 9af5b30..75cb2b1 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -42,7 +42,7 @@ msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:247 +#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" @@ -159,10 +159,10 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:80 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:230 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:124 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:125 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,10 +253,10 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:83 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:233 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:76 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:77 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -314,7 +314,7 @@ msgstr "" msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:53 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -336,7 +336,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:209 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "" @@ -357,7 +357,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:78 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -377,7 +377,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:231 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" @@ -402,7 +402,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:248 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -413,7 +413,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:71 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -430,7 +430,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:247 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -621,7 +621,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:131 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -636,7 +636,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:81 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -646,32 +646,32 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:48 +#: lib/mv_web/live/custom_field_live/form.ex:45 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Use this form to manage custom_field_value records in your database." -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/user_live/form.ex:209 +#: lib/mv_web/live/custom_field_value_live/form.ex:42 #, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgid "Use this form to manage Custom Field Value records in your database." msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Auto-generated identifier (immutable)" msgstr "" -#: lib/mv_web/live/user_live/form.ex:184 +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:185 #, elixir-autogen, elixir-format msgid "Available members" msgstr "" @@ -681,7 +681,7 @@ msgstr "" msgid "Member will be unlinked when you save. Cannot select new member until saved." msgstr "" -#: lib/mv_web/live/user_live/form.ex:222 +#: lib/mv_web/live/user_live/form.ex:226 #, elixir-autogen, elixir-format msgid "Save to confirm linking." msgstr "" @@ -696,16 +696,11 @@ msgstr "" msgid "Search for member to link" msgstr "" -#: lib/mv_web/live/user_live/form.ex:219 +#: lib/mv_web/live/user_live/form.ex:223 #, elixir-autogen, elixir-format msgid "Selected" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:58 -#, elixir-autogen, elixir-format -msgid "Slug" -msgstr "" - #: lib/mv_web/live/user_live/form.ex:143 #, elixir-autogen, elixir-format msgid "Unlink Member" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index aefc0d9..7cae329 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -42,7 +42,7 @@ msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:247 +#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" @@ -159,10 +159,10 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:80 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:230 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:124 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:125 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,10 +253,10 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:83 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:233 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:76 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:77 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -314,7 +314,7 @@ msgstr "" msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:53 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -336,7 +336,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:209 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" @@ -357,7 +357,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:78 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -377,7 +377,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:231 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" @@ -402,7 +402,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:248 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -413,7 +413,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:71 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -430,7 +430,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:247 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -621,7 +621,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:131 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -636,7 +636,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:81 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -646,32 +646,32 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:48 +#: lib/mv_web/live/custom_field_live/form.ex:45 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format, fuzzy -msgid "Use this form to manage custom_field_value records in your database." -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/user_live/form.ex:209 -#, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage Custom Field Value records in your database." msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Auto-generated identifier (immutable)" msgstr "" -#: lib/mv_web/live/user_live/form.ex:184 +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:185 #, elixir-autogen, elixir-format msgid "Available members" msgstr "" @@ -681,7 +681,7 @@ msgstr "" msgid "Member will be unlinked when you save. Cannot select new member until saved." msgstr "" -#: lib/mv_web/live/user_live/form.ex:222 +#: lib/mv_web/live/user_live/form.ex:226 #, elixir-autogen, elixir-format msgid "Save to confirm linking." msgstr "" @@ -696,16 +696,11 @@ msgstr "" msgid "Search for member to link" msgstr "" -#: lib/mv_web/live/user_live/form.ex:219 +#: lib/mv_web/live/user_live/form.ex:223 #, elixir-autogen, elixir-format, fuzzy msgid "Selected" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:58 -#, elixir-autogen, elixir-format -msgid "Slug" -msgstr "" - #: lib/mv_web/live/user_live/form.ex:143 #, elixir-autogen, elixir-format msgid "Unlink Member" @@ -715,3 +710,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/form.ex:58 +#~ #, elixir-autogen, elixir-format +#~ msgid "Slug" +#~ msgstr ""