diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md new file mode 100644 index 0000000..f9de090 --- /dev/null +++ b/docs/roles-and-permissions-architecture.md @@ -0,0 +1,2279 @@ +# Roles and Permissions Architecture + +**Project:** Mila - Membership Management System +**Feature:** Role-Based Access Control (RBAC) with Permission Sets +**Version:** 1.0 +**Last Updated:** 2025-11-10 +**Status:** Architecture Design + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Requirements Analysis](#requirements-analysis) +3. [Evaluated Approaches](#evaluated-approaches) +4. [Selected Architecture](#selected-architecture) +5. [Database Schema](#database-schema) +6. [Permission System Design](#permission-system-design) +7. [Implementation Details](#implementation-details) +8. [Future Extensions](#future-extensions) +9. [Migration Strategy](#migration-strategy) +10. [Security Considerations](#security-considerations) + +--- + +## Overview + +This document describes the architecture for implementing a flexible, scalable role-based access control (RBAC) system for the Mila membership management application. The system provides: + +- **Predefined Permission Sets** with configurable permissions +- **Dynamic Roles** that reference permission sets +- **Resource-level and Action-level** authorization +- **Page-level** access control for LiveView routes +- **Special handling** for credentials and linked user-member relationships +- **Future extensibility** for field-level permissions + +### Key Design Principles + +1. **Separation of Concerns:** Permission Sets (what you can do) vs. Roles (job titles/functions) +2. **Flexibility:** Admins can configure permissions at runtime via database +3. **Performance:** Leverage Ash Framework policies with ETS caching +4. **Extensibility:** Architecture supports future field-level granularity +5. **Consistency:** Single unified permission model for all resources + +--- + +## Requirements Analysis + +### Core Requirements + +Based on the project requirements, the system must support: + +#### Permission Sets (4 Predefined) + +1. **Own Data** - Users can only access their own data +2. **Read-Only** - Read access to all members, groups, and custom fields +3. **Normal User** - Read and write access to members and custom fields +4. **Admin** - Full access to all resources including user management + +#### Example Roles + +- **Mitglied** (Member) - Default role, own data access +- **Vorstand** (Board) - Access to members, not users +- **Kassenwart** (Treasurer) - Access to payment information +- **Buchhaltung** (Accounting) - Read-only access +- **Admin** - Full administrative access + +#### Authorization Granularity + +**Resource Level (Phase 1 - Now):** +- Member: read, create, update, destroy +- User: read, create, update, destroy +- PropertyType: read, create, update, destroy +- Property: read, create, update, destroy +- Role: read, create, update, destroy +- Payment (future): read, create, update, destroy + +**Page Level:** +- Control access to LiveView pages +- Pages are read-only access checks +- Edit pages require both page access AND resource write permission + +**Field Level (Phase 2 - Later):** +- Restrict read/write access to specific member fields +- Restrict access to specific custom field types +- Example: Treasurer sees payment_history, Board does not + +#### Special Cases + +1. **User Credentials:** + - Users can ALWAYS edit their own credentials (email, password) + - Only Admins can edit OTHER users' credentials + - Email field of members linked to users can only be edited by Admins + +2. **Required Fields:** + - When creating a member with required custom fields, user must be able to write those fields + - Even if user normally doesn't have write permission for that field type + - After creation, normal permissions apply + +3. **Payment History (Future):** + - Configurable per permission set + - Members may or may not see their own payment history + +4. **Linked User-Member Relationships:** + - Member email sync follows special rules + - User email is source of truth for linked members + +#### Constraints + +- Roles can be added, renamed, or removed by admins +- Permission sets are predefined but permissions are configurable +- Each user has exactly ONE role +- Roles cannot overlap (no multiple role assignment per user) +- "Mitglied" role is a system role and cannot be deleted +- Permission sets are system-defined and cannot be deleted + +--- + +## Evaluated Approaches + +We evaluated four different architectural approaches for implementing the authorization system: + +### Approach 1: Ash Policies + RBAC with JSONB Permissions + +**Description:** Store permissions as JSONB in the Role resource, use custom Ash Policy checks to evaluate them. + +**Database Structure:** +``` +roles (id, name, permissions_config: jsonb) +users (role_id) +``` + +**Permissions stored as:** +```json +{ + "resource_permissions": { + "Member": {"read": true, "update": false} + }, + "page_permissions": { + "/members": true + } +} +``` + +**Advantages:** +- ✅ Simple database schema (fewer tables) +- ✅ Flexible JSON structure +- ✅ Fast schema changes (no migrations needed) +- ✅ Easy to serialize/deserialize + +**Disadvantages:** +- ❌ No referential integrity on permission keys +- ❌ JSONB queries are less efficient than normalized tables +- ❌ Difficult to query "which roles have access to X?" +- ❌ Schema validation happens in application code +- ❌ No indexing on individual permissions +- ❌ Versioning of JSONB structure becomes complex + +**Verdict:** ❌ Not selected - JSONB makes querying and validation difficult + +--- + +### Approach 2: Hybrid RBAC + ABAC with Permission Matrix + +**Description:** Separate tables for every permission type with full granularity from day one. + +**Database Structure:** +``` +roles (id, name) +permissions (id, resource, action, field, condition) +role_permissions (role_id, permission_id, granted) +user_roles (user_id, role_id) +``` + +**Advantages:** +- ✅ Maximum flexibility +- ✅ Highly granular from the start +- ✅ Easy to add new permission types +- ✅ Audit trail built-in + +**Disadvantages:** +- ❌ Very complex database schema +- ❌ High JOIN overhead on every authorization check +- ❌ Over-engineered for current requirements +- ❌ Difficult to cache effectively +- ❌ Performance concerns with many permissions +- ❌ Complex to seed and maintain + +**Verdict:** ❌ Not selected - Too complex for current needs, over-engineering + +--- + +### Approach 3: Policy Graphs with Custom Authorizer + +**Description:** Use Ash Policies for action-level checks, custom Authorizer module for field-level filtering. + +**Database Structure:** +``` +roles (id, name, permission_config) +Custom Authorizer reads config and applies filters +``` + +**Advantages:** +- ✅ Best performance (optimized for Ash) +- ✅ Granular field-level control +- ✅ Can leverage Ash query optimization + +**Disadvantages:** +- ❌ Requires custom authorizer implementation (non-standard) +- ❌ More code to maintain +- ❌ Harder to test than declarative policies +- ❌ Mixes declarative (Policies) and imperative (Authorizer) approaches + +**Verdict:** ❌ Not selected - Too much custom code, reduces maintainability + +--- + +### Approach 4: Simple Role Enum (Quick Start) + +**Description:** Simple `:role` field on User with enum values, policies hardcoded in resources. + +**Database Structure:** +``` +users (role: :admin | :vorstand | :kassenwart | :member) +``` + +**Advantages:** +- ✅ Very simple to implement (1 week) +- ✅ No extra tables needed +- ✅ Fast performance +- ✅ Easy to understand + +**Disadvantages:** +- ❌ No dynamic permission configuration +- ❌ Requires code deployment to change permissions +- ❌ Can't add new roles without code changes +- ❌ Not extensible to field-level permissions +- ❌ Doesn't meet requirement for "configurable permissions" + +**Verdict:** ❌ Not selected - Doesn't meet core requirements + +--- + +## Selected Architecture + +### Approach 5: Permission Sets + Normalized Tables (Selected) + +**Description:** Hybrid approach that separates Permission Sets (what you can do) from Roles (who you are), with normalized database tables for queryability and Ash Policies for enforcement. + +**Key Innovation:** Introduce **Permission Sets** as an abstraction layer between Roles and actual Permissions. + +``` +Permission Set (4 predefined, defines capabilities) + ↓ +Role (many, references one Permission Set) + ↓ +User (each has one Role) +``` + +**Why This Approach?** + +1. **Meets Requirements:** + - ✅ Configurable permissions (stored in database) + - ✅ Dynamic role creation + - ✅ Extensible to field-level + - ✅ Admin UI can modify at runtime + +2. **Performance:** + - ✅ Normalized tables allow efficient queries + - ✅ Indexes on resource_name and action + - ✅ ETS cache for permission lookups + - ✅ Ash Policies translate to SQL filters + +3. **Maintainability:** + - ✅ Clear separation of concerns + - ✅ Standard Ash patterns (not custom authorizer) + - ✅ Testable with standard Ash policy tests + - ✅ Easy to understand and debug + +4. **Extensibility:** + - ✅ `field_name` column reserved for Phase 2 + - ✅ `scope` system handles "own" vs "all" vs "linked" + - ✅ New resources just add permission rows + - ✅ No code changes needed for new roles + +5. **Flexibility:** + - ✅ Permission Sets ensure consistency + - ✅ Roles can be renamed without changing permissions + - ✅ Multiple roles can share same permission set + - ✅ Admin can configure at runtime + +**Trade-offs Accepted:** +- More tables than JSONB approach (but better queryability) +- More rows than enum approach (but runtime configurable) +- Not as granular as full ABAC (but simpler to manage) + +--- + +## Database Schema + +### Entity Relationship Diagram + +``` +┌─────────────────────┐ +│ permission_sets │ +│─────────────────────│ +│ id (PK) │ +│ name │◄───────┐ +│ description │ │ +│ is_system │ │ +└─────────────────────┘ │ + │ + │ +┌─────────────────────────────┐│ +│ permission_set_resources ││ +│─────────────────────────────││ +│ id (PK) ││ +│ permission_set_id (FK) │┘ +│ resource_name │ +│ action │ +│ scope │ +│ field_name (nullable) │ +│ granted │ +└─────────────────────────────┘ + +┌─────────────────────────────┐ +│ permission_set_pages │ +│─────────────────────────────│ +│ id (PK) │ +│ permission_set_id (FK) │───┐ +│ page_path │ │ +└─────────────────────────────┘ │ + │ + │ +┌─────────────────────┐ │ +│ roles │ │ +│─────────────────────│ │ +│ id (PK) │ │ +│ name │ │ +│ description │ │ +│ permission_set_id │──────────┘ +│ is_system_role │ +└─────────────────────┘ + ▲ + │ + │ +┌─────────────────────┐ +│ users │ +│─────────────────────│ +│ id (PK) │ +│ email │ +│ hashed_password │ +│ oidc_id │ +│ member_id (FK) │ +│ role_id (FK) │◄──── Default: "Mitglied" role +└─────────────────────┘ +``` + +### Table Definitions + +#### `permission_sets` + +Defines the 4 core permission sets. These are system-defined and cannot be deleted. + +```sql +CREATE TABLE permission_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_permission_sets_name ON permission_sets(name); +``` + +**Records:** +- `own_data` - Users can only access their own data +- `read_only` - Read access to all members and custom fields +- `normal_user` - Read and write access to members and custom fields +- `admin` - Full access to everything + +--- + +#### `permission_set_resources` + +Defines what actions each permission set can perform on which resources. + +```sql +CREATE TABLE permission_set_resources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, + resource_name VARCHAR(255) NOT NULL, -- "Member", "User", "PropertyType", etc. + action VARCHAR(50) NOT NULL, -- "read", "create", "update", "destroy" + scope VARCHAR(50), -- NULL/"all", "own", "linked" + field_name VARCHAR(255), -- NULL = all fields, else specific field (Phase 2) + granted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_psr_permission_set ON permission_set_resources(permission_set_id); +CREATE INDEX idx_psr_resource_action ON permission_set_resources(resource_name, action); +CREATE UNIQUE INDEX idx_psr_unique ON permission_set_resources( + permission_set_id, resource_name, action, + COALESCE(scope, 'all'), COALESCE(field_name, '') +); +``` + +**Scope Values:** +- `NULL` or `"all"` - Permission applies to all entities of this resource +- `"own"` - Permission applies only to user's own data (user.id == actor.id) +- `"linked"` - Permission applies only to entities linked to user (e.g., member.user_id == actor.id) + +**Field Name (Phase 2):** +- `NULL` - Permission applies to all fields (Phase 1 default) +- `"field_name"` - Permission applies only to specific field (Phase 2) + +**Example Records:** +```sql +-- Own Data Permission Set: User can read their own User record +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (own_data_id, 'User', 'read', 'own', true); + +-- Read-Only Permission Set: Can read all Members +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (read_only_id, 'Member', 'read', 'all', true); + +-- Normal User Permission Set: Can update all Members +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (normal_user_id, 'Member', 'update', 'all', true); + +-- Admin Permission Set: Can destroy all Members +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (admin_id, 'Member', 'destroy', 'all', true); +``` + +--- + +#### `permission_set_pages` + +Defines which LiveView pages each permission set can access. + +```sql +CREATE TABLE permission_set_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, + page_path VARCHAR(255) NOT NULL, -- "/members", "/members/:id/edit", "/admin" + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_psp_permission_set ON permission_set_pages(permission_set_id); +CREATE INDEX idx_psp_page_path ON permission_set_pages(page_path); +CREATE UNIQUE INDEX idx_psp_unique ON permission_set_pages(permission_set_id, page_path); +``` + +**Page Paths:** +- Static paths: `/members`, `/users`, `/admin` +- Dynamic paths: `/members/:id`, `/members/:id/edit` +- Must match Phoenix Router routes exactly + +**Important:** Page permissions are READ-ONLY access checks. If a user shouldn't access an edit page, they don't get the page permission. The actual write operation is controlled by resource permissions. + +**Example Records:** +```sql +-- Own Data: Only profile page +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES (own_data_id, '/profile'); + +-- Read-Only: Member index and show pages +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES + (read_only_id, '/members'), + (read_only_id, '/members/:id'); + +-- Normal User: Member pages including edit +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES + (normal_user_id, '/members'), + (normal_user_id, '/members/new'), + (normal_user_id, '/members/:id'), + (normal_user_id, '/members/:id/edit'); + +-- Admin: All pages +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES + (admin_id, '/members'), + (admin_id, '/members/new'), + (admin_id, '/members/:id'), + (admin_id, '/members/:id/edit'), + (admin_id, '/users'), + (admin_id, '/users/new'), + (admin_id, '/users/:id'), + (admin_id, '/users/:id/edit'), + (admin_id, '/admin'); +``` + +--- + +#### `roles` + +Defines user roles that reference one permission set each. + +```sql +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE RESTRICT, + is_system_role BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_roles_name ON roles(name); +CREATE INDEX idx_roles_permission_set ON roles(permission_set_id); +``` + +**System Roles:** +- `is_system_role = true` for "Mitglied" (default role) +- System roles cannot be deleted +- Can be renamed but must always exist + +**Example Records:** +```sql +-- Mitglied (default role for all users) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Mitglied', 'Standard role for all members', own_data_id, true); + +-- Vorstand (board member with read access) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Vorstand', 'Board member with read access to all members', read_only_id, false); + +-- Kassenwart (treasurer with write access + payment info) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Kassenwart', 'Treasurer with access to payment information', normal_user_id, false); + +-- Buchhaltung (accounting with read access) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Buchhaltung', 'Accounting with read-only access', read_only_id, false); + +-- Admin (full access) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Admin', 'Full administrative access', admin_id, false); +``` + +--- + +#### `users` (Extended) + +Add `role_id` foreign key to existing users table. + +```sql +ALTER TABLE users +ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; + +-- Set default to "Mitglied" role (via migration) +UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL; + +ALTER TABLE users ALTER COLUMN role_id SET NOT NULL; + +-- Index +CREATE INDEX idx_users_role ON users(role_id); +``` + +--- + +## Permission System Design + +### Permission Evaluation Flow + +``` +Request comes in (LiveView mount or Ash action) + ↓ +1. Load Current User with Role preloaded + ↓ +2. Check Page Permission (if LiveView) + - Query: permission_set_pages WHERE page_path = current_path + - If no match: DENY, redirect to "/" + ↓ +3. Ash Policy Check (for resource actions) + - Policy 1: Check "relates_to_actor" (own data) + - Policy 2: Check custom permission via DB + - Load permission_set_resources + - Match: resource_name, action, scope + - Evaluate scope: + * "own" → Filter: id == actor.id + * "linked" → Filter: user_id == actor.id + * "all" → No filter + - Policy 3: Default DENY + ↓ +4. Special Validations (if applicable) + - Member email change on linked member + - Required fields on create + ↓ +5. Execute Action or Render Page +``` + +### Scope Evaluation + +The `scope` field determines which subset of records a permission applies to: + +#### Scope: `"own"` + +Used for resources where user has direct ownership. + +**Applicable to:** `User` + +**Filter Logic:** +```elixir +{:filter, expr(id == ^actor.id)} +``` + +**Example:** +- Own Data permission set has `User.read` with scope `"own"` +- User can only read their own User record +- Query becomes: `SELECT * FROM users WHERE id = $actor_id` + +--- + +#### Scope: `"linked"` + +Used for resources linked to user via intermediate relationship. + +**Applicable to:** `Member`, `Property`, `Payment` (future) + +**Filter Logic:** +```elixir +# For Member +{:filter, expr(user_id == ^actor.id)} + +# For Property (traverses relationship) +{:filter, expr(member.user_id == ^actor.id)} + +# For Payment (future, traverses relationship) +{:filter, expr(member.user_id == ^actor.id)} +``` + +**Example:** +- Own Data permission set has `Member.read` with scope `"linked"` +- User can only read Members linked to them (member.user_id == actor.id) +- If user has no linked member: no results +- Query becomes: `SELECT * FROM members WHERE user_id = $actor_id` + +--- + +#### Scope: `"all"` or `NULL` + +Used for full access to all records of a resource. + +**Applicable to:** All resources + +**Filter Logic:** +```elixir +:authorized # No filter, all records allowed +``` + +**Example:** +- Read-Only permission set has `Member.read` with scope `"all"` +- User can read all Members +- Query becomes: `SELECT * FROM members` (no WHERE clause for authorization) + +--- + +### Policy Implementation in Ash Resources + +Each Ash resource defines policies that check permissions: + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, ... + + policies do + # Policy 1: Users can always access their own linked member data + # This bypasses permission checks for own data + policy action_type([:read, :update]) do + description "Users can always access their own member data if linked" + authorize_if relates_to_actor_via(:user) + end + + # Policy 2: Check database permissions + # This is where permission_set_resources table is queried + policy action_type([:read, :create, :update, :destroy]) do + description "Check if actor's role has permission for this action" + authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() + end + + # Policy 3: Default deny + # If no policy matched, forbid access + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end +end +``` + +**Important:** Policy order matters! First matching policy wins. + +--- + +### Custom Policy Check Implementation + +```elixir +defmodule Mv.Authorization.Checks.HasResourcePermission do + @moduledoc """ + Custom Ash Policy Check that evaluates database-stored permissions. + + Queries the permission_set_resources table based on actor's role + and evaluates scope to return appropriate filter. + """ + + use Ash.Policy.Check + + @impl true + def type, do: :filter + + @impl true + def match?(actor, context, _opts) do + resource = context.resource + action = context.action + + # Load actor's permission set (with caching) + case get_permission_set(actor) do + nil -> + :forbidden + + permission_set -> + # Query permission_set_resources table + check_permission(permission_set.id, resource, action.name, actor, context) + end + end + + defp get_permission_set(nil), do: nil + defp get_permission_set(actor) do + # Try cache first (ETS) + case Mv.Authorization.PermissionCache.get_permission_set(actor.id) do + {:ok, permission_set} -> + permission_set + + :miss -> + # Load from database: user → role → permission_set + load_and_cache_permission_set(actor) + end + end + + defp load_and_cache_permission_set(actor) do + case Ash.load(actor, role: :permission_set) do + {:ok, user_with_relations} -> + permission_set = user_with_relations.role.permission_set + Mv.Authorization.PermissionCache.put_permission_set(actor.id, permission_set) + permission_set + + _ -> + nil + end + end + + defp check_permission(permission_set_id, resource, action, actor, context) do + resource_name = resource |> Module.split() |> List.last() + + # Query permission_set_resources + query = + Mv.Authorization.PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^permission_set_id and + resource_name == ^resource_name and + action == ^action and + is_nil(field_name) # Phase 1: only resource-level + ) + + case Ash.read_one(query) do + {:ok, permission} -> + evaluate_permission(permission, actor, context) + + _ -> + :forbidden + end + end + + defp evaluate_permission(%{granted: false}, _actor, _context) do + :forbidden + end + + defp evaluate_permission(%{granted: true, scope: nil}, _actor, _context) do + :authorized + end + + defp evaluate_permission(%{granted: true, scope: "all"}, _actor, _context) do + :authorized + end + + defp evaluate_permission(%{granted: true, scope: "own"}, actor, _context) do + # Return filter expression for Ash + {:filter, expr(id == ^actor.id)} + end + + defp evaluate_permission(%{granted: true, scope: "linked"}, actor, context) do + resource = context.resource + + # Generate appropriate filter based on resource + case resource do + Mv.Membership.Member -> + {:filter, expr(user_id == ^actor.id)} + + Mv.Membership.Property -> + {:filter, expr(member.user_id == ^actor.id)} + + # Add more resources as needed + + _ -> + :forbidden + end + end +end +``` + +--- + +## Implementation Details + +### Phase 1: Resource and Page Level Permissions + +**Timeline:** Sprint 1-2 (2-3 weeks) + +**Deliverables:** +1. Database migrations for all permission tables +2. Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage +3. Custom policy checks +4. Permission cache (ETS) +5. Router plug for page permissions +6. Seeds for 4 permission sets and 5 roles +7. Admin UI for role management +8. Tests for all permission scenarios + +**Not Included in Phase 1:** +- Field-level permissions (field_name is always NULL) +- Payment history (resource doesn't exist yet) +- Groups (not yet planned) + +--- + +### Special Cases Implementation + +#### 1. User Credentials - Always Editable by Owner + +**Requirement:** Users can always edit their own email and password, regardless of permission set. + +**Implementation:** + +```elixir +defmodule Mv.Accounts.User do + policies do + # Policy 1: Users can ALWAYS read and update their own credentials + # This comes BEFORE permission checks + policy action_type([:read, :update]) do + description "Users can always access and update their own credentials" + authorize_if expr(id == ^actor(:id)) + end + + # Policy 2: Check permission set (for admins accessing other users) + policy action_type([:read, :create, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() + end + + # Policy 3: Default deny + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end +end +``` + +**Result:** +- Mitglied role: Can edit own User record (own email/password) +- Admin role: Can edit ANY User record (including others' credentials) +- Other roles: Cannot access User resource unless specifically granted + +--- + +#### 2. Member Email for Linked Members - Admin Only + +**Requirement:** If a member is linked to a user, only admins can edit the member's email field. + +**Implementation:** + +```elixir +defmodule Mv.Membership.Member do + validations do + validate fn changeset, context -> + # Only check if email is being changed + if Ash.Changeset.changing_attribute?(changeset, :email) do + member = changeset.data + actor = context.actor + + # Load member's user relationship + case Ash.load(member, :user) do + {:ok, %{user: %{id: _user_id}}} -> + # Member IS linked to a user + # Check if actor has permission to edit ALL users + if has_permission_for_all_users?(actor) do + :ok + else + {:error, + field: :email, + message: "Only admins can edit email of members linked to users"} + end + + {:ok, %{user: nil}} -> + # Member is NOT linked + # Normal Member.update permission applies + :ok + + {:error, _} -> + :ok + end + else + :ok + end + end + end + + defp has_permission_for_all_users?(actor) do + # Check if actor's permission set has User.update with scope="all" + permission_set = get_permission_set(actor) + + Mv.Authorization.PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^permission_set.id and + resource_name == "User" and + action == "update" and + scope == "all" and + granted == true + ) + |> Ash.exists?() + end +end +``` + +**Result:** +- Admin: Can edit email of any member (including linked ones) +- Normal User/Read-Only: Can edit email of unlinked members only +- Attempting to edit email of linked member without permission: Validation error + +--- + +#### 3. Required Custom Fields on Member Creation + +**Requirement:** When creating a member with required custom fields, user must be able to set those fields even if they normally don't have permission. + +**Implementation:** + +For Phase 1, this is not an issue because: +- PropertyType.required flag exists but isn't enforced yet +- No field-level permissions exist yet +- If user has Member.create permission, they can set Properties + +For Phase 2 (when field-level permissions exist): + +```elixir +defmodule Mv.Membership.Property do + actions do + create :create_property do + # Special handling for required properties during member creation + change Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate + end + end +end + +defmodule Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate do + use Ash.Resource.Change + + def change(changeset, _opts, context) do + # Check if this is part of a member creation + if creating_member?(context) do + property_type_id = Ash.Changeset.get_attribute(changeset, :property_type_id) + + # Load PropertyType + case Ash.get(Mv.Membership.PropertyType, property_type_id) do + {:ok, %{required: true}} -> + # This is a required field, allow creation even without normal permission + # Set special context flag + Ash.Changeset.set_context(changeset, :bypass_property_permission, true) + + _ -> + changeset + end + else + changeset + end + end +end +``` + +--- + +### Page Permission Implementation + +**Router Configuration:** + +```elixir +defmodule MvWeb.Router do + use MvWeb, :router + import MvWeb.Authorization + + # Pipeline with permission check + pipeline :require_page_permission do + plug :put_secure_browser_headers + plug :fetch_current_user + plug MvWeb.Plugs.CheckPagePermission + end + + scope "/", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + # These routes automatically check page permissions + live "/members", MemberLive.Index, :index + live "/members/new", MemberLive.Index, :new + live "/members/:id", MemberLive.Show, :show + live "/members/:id/edit", MemberLive.Index, :edit + + live "/users", UserLive.Index, :index + live "/users/new", UserLive.Index, :new + live "/users/:id", UserLive.Show, :show + live "/users/:id/edit", UserLive.Index, :edit + + live "/property-types", PropertyTypeLive.Index, :index + live "/property-types/new", PropertyTypeLive.Index, :new + live "/property-types/:id/edit", PropertyTypeLive.Index, :edit + + live "/admin", AdminLive.Dashboard, :index + end +end +``` + +**Page Permission Plug:** + +```elixir +defmodule MvWeb.Plugs.CheckPagePermission do + @moduledoc """ + Plug that checks if current user has permission to access the current page. + + Queries permission_set_pages table based on user's role → permission_set. + """ + + import Plug.Conn + import Phoenix.Controller + + 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 + conn + |> put_flash(:error, "You don't have permission to access this page.") + |> redirect(to: "/") + |> halt() + end + end + + defp get_page_path(conn) do + # Extract route template from conn + # "/members/:id/edit" from actual "/members/123/edit" + case conn.private[:phoenix_route] do + {_, _, _, route_template, _} -> route_template + _ -> conn.request_path + end + end + + defp has_page_permission?(nil, _page_path), do: false + defp has_page_permission?(user, page_path) do + # Try cache first + case Mv.Authorization.PermissionCache.get_page_permission(user.id, page_path) do + {:ok, has_permission} -> + has_permission + + :miss -> + # Load from database and cache + has_permission = check_page_permission_db(user, page_path) + Mv.Authorization.PermissionCache.put_page_permission(user.id, page_path, has_permission) + has_permission + end + end + + defp check_page_permission_db(user, page_path) do + # Load user → role → permission_set + case Ash.load(user, role: :permission_set) do + {:ok, user_with_relations} -> + permission_set_id = user_with_relations.role.permission_set.id + + # Check if permission_set_pages has this page + Mv.Authorization.PermissionSetPage + |> Ash.Query.filter( + permission_set_id == ^permission_set_id and + page_path == ^page_path + ) + |> Ash.exists?() + + _ -> + false + end + end +end +``` + +**Important:** Both page permission AND resource permission must be true for edit operations: +- User needs `/members/:id/edit` page permission to see the page +- User needs `Member.update` permission to actually save changes +- If user has page permission but not resource permission: page loads but save fails + +--- + +### Permission Cache Implementation + +**ETS Cache for Performance:** + +```elixir +defmodule Mv.Authorization.PermissionCache do + @moduledoc """ + ETS-based cache for user permissions to avoid database lookups on every request. + + Cache stores: + - User's permission_set (user_id → permission_set) + - Page permissions (user_id + page_path → boolean) + - Resource permissions (user_id + resource + action → permission) + + Cache is invalidated when: + - User's role changes + - Role's permission_set changes + - Permission set's permissions change + """ + + use GenServer + + @table_name :permission_cache + + # Client API + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def get_permission_set(user_id) do + case :ets.lookup(@table_name, {:permission_set, user_id}) do + [{_, permission_set}] -> {:ok, permission_set} + [] -> :miss + end + end + + def put_permission_set(user_id, permission_set) do + :ets.insert(@table_name, {{:permission_set, user_id}, permission_set}) + :ok + end + + def get_page_permission(user_id, page_path) do + case :ets.lookup(@table_name, {:page, user_id, page_path}) do + [{_, has_permission}] -> {:ok, has_permission} + [] -> :miss + end + end + + def put_page_permission(user_id, page_path, has_permission) do + :ets.insert(@table_name, {{:page, user_id, page_path}, has_permission}) + :ok + end + + def invalidate_user(user_id) do + # Delete all entries for this user + :ets.match_delete(@table_name, {{:permission_set, user_id}, :_}) + :ets.match_delete(@table_name, {{:page, user_id, :_}, :_}) + :ok + end + + def invalidate_all do + :ets.delete_all_objects(@table_name) + :ok + end + + # Server Callbacks + + def init(_) do + table = :ets.new(@table_name, [ + :set, + :public, + :named_table, + read_concurrency: true, + write_concurrency: true + ]) + {:ok, %{table: table}} + end +end +``` + +**Cache Invalidation Strategy:** + +```elixir +defmodule Mv.Authorization.Role do + # After updating role's permission_set + changes do + change after_action(fn changeset, role, _context -> + # Invalidate cache for all users with this role + invalidate_users_with_role(role.id) + {:ok, role} + end), on: [:update] + end + + defp invalidate_users_with_role(role_id) do + # Find all users with this role + users = + Mv.Accounts.User + |> Ash.Query.filter(role_id == ^role_id) + |> Ash.read!() + + # Invalidate each user's cache + Enum.each(users, fn user -> + Mv.Authorization.PermissionCache.invalidate_user(user.id) + end) + end +end + +defmodule Mv.Authorization.PermissionSetResource do + # After updating permissions + changes do + change after_action(fn changeset, permission, _context -> + # Invalidate all users with this permission set + invalidate_permission_set(permission.permission_set_id) + {:ok, permission} + end), on: [:create, :update, :destroy] + end + + defp invalidate_permission_set(permission_set_id) do + # Find all roles with this permission set + roles = + Mv.Authorization.Role + |> Ash.Query.filter(permission_set_id == ^permission_set_id) + |> Ash.read!() + + # Invalidate all users with these roles + Enum.each(roles, fn role -> + invalidate_users_with_role(role.id) + end) + end +end +``` + +--- + +### UI-Level Authorization + +**Requirement:** The user interface should only display links, buttons, and fields that the user has permission to access. This improves UX and prevents confusion. + +**Key Principles:** +1. **Navigation Links:** Hide links to pages the user cannot access +2. **Action Buttons:** Hide "Edit", "Delete", "New" buttons when user lacks permissions +3. **Form Fields:** In Phase 2, hide fields the user cannot read/write +4. **Proactive UI:** Never show a clickable element that would result in "Forbidden" + +--- + +#### Implementation Approach + +**Helper Module:** `MvWeb.Authorization` + +```elixir +defmodule MvWeb.Authorization do + @moduledoc """ + UI-level authorization helpers for LiveView. + + These helpers check permissions and determine what UI elements to show. + They work in conjunction with Ash Policies (which are the actual enforcement). + """ + + alias Mv.Authorization.PermissionCache + alias Mv.Authorization + + @doc """ + Checks if actor can perform action on resource. + + ## Examples + + # In LiveView template + <%= if can?(@current_user, :update, Mv.Membership.Member) do %> + + <% end %> + + # In LiveView module + if can?(socket.assigns.current_user, :create, Mv.Membership.PropertyType) do + # Show "New Custom Field" button + end + """ + def can?(nil, _action, _resource), do: false + + def can?(user, action, resource) when is_atom(action) and is_atom(resource) do + resource_name = resource_name(resource) + + # Check cache first + case get_permission_from_cache(user.id, resource_name, action) do + {:ok, result} -> result + :miss -> check_permission_from_db(user, resource_name, action) + end + end + + @doc """ + Checks if actor can access a specific page path. + + ## Examples + + # In navigation component + <%= if can_access_page?(@current_user, "/members") do %> + <.link navigate="/members">Members + <% end %> + """ + def can_access_page?(nil, _page_path), do: false + + def can_access_page?(user, page_path) do + # Check cache first + case PermissionCache.get_page_permission(user.id, page_path) do + {:ok, result} -> result + :miss -> check_page_permission_from_db(user, page_path) + end + end + + @doc """ + Checks if actor can perform action on a specific record. + + This respects scope restrictions (own, linked, all). + + ## Examples + + # Show edit button only if user can edit THIS member + <%= if can?(@current_user, :update, member) do %> + + <% end %> + """ + def can?(nil, _action, _record), do: false + + def can?(user, action, %resource{} = record) when is_atom(action) do + resource_name = resource_name(resource) + + # First check if user has any permission for this action + case get_permission_from_cache(user.id, resource_name, action) do + {:ok, false} -> + false + + {:ok, true} -> + # User has permission, now check scope + check_scope_for_record(user, action, resource, record) + + :miss -> + check_permission_and_scope_from_db(user, action, resource, record) + end + end + + # Private helpers + + defp resource_name(Mv.Accounts.User), do: "User" + defp resource_name(Mv.Membership.Member), do: "Member" + defp resource_name(Mv.Membership.Property), do: "Property" + defp resource_name(Mv.Membership.PropertyType), do: "PropertyType" + + defp get_permission_from_cache(user_id, resource_name, action) do + # Try to get from cache + # Returns {:ok, true}, {:ok, false}, or :miss + case PermissionCache.get_permission_set(user_id) do + {:ok, permission_set} -> + # Check if this permission set has the permission + has_permission = + permission_set.resources + |> Enum.any?(fn p -> + p.resource_name == resource_name and + p.action == to_string(action) and + p.granted == true + end) + + {:ok, has_permission} + + :miss -> + :miss + end + end + + defp check_permission_from_db(user, resource_name, action) do + # Load user's role and permission set + user = Ash.load!(user, role: [permission_set: :resources]) + + has_permission = + user.role.permission_set.resources + |> Enum.any?(fn p -> + p.resource_name == resource_name and + p.action == to_string(action) and + p.granted == true + end) + + # Cache the entire permission set + PermissionCache.put_permission_set(user.id, user.role.permission_set) + + has_permission + end + + defp check_page_permission_from_db(user, page_path) do + user = Ash.load!(user, role: [permission_set: :pages]) + + has_access = + user.role.permission_set.pages + |> Enum.any?(fn p -> p.page_path == page_path end) + + # Cache this specific page permission + PermissionCache.put_page_permission(user.id, page_path, has_access) + + has_access + end + + defp check_scope_for_record(user, action, resource, record) do + # Load the permission to check scope + user = Ash.load!(user, role: [permission_set: :resources]) + resource_name = resource_name(resource) + + permission = + user.role.permission_set.resources + |> Enum.find(fn p -> + p.resource_name == resource_name and + p.action == to_string(action) and + p.granted == true + end) + + case permission do + nil -> + false + + %{scope: "all"} -> + true + + %{scope: "own"} when resource == Mv.Accounts.User -> + # Check if record.id == user.id + record.id == user.id + + %{scope: "linked"} when resource == Mv.Membership.Member -> + # Check if record.user_id == user.id + record_with_user = Ash.load!(record, :user) + case record_with_user.user do + nil -> false + %{id: user_id} -> user_id == user.id + end + + %{scope: "linked"} when resource == Mv.Membership.Property -> + # Check if record.member.user_id == user.id + record_with_member = Ash.load!(record, member: :user) + case record_with_member.member do + nil -> false + %{user: nil} -> false + %{user: %{id: user_id}} -> user_id == user.id + end + + _ -> + false + end + end + + defp check_permission_and_scope_from_db(user, action, resource, record) do + case check_permission_from_db(user, resource_name(resource), action) do + false -> false + true -> check_scope_for_record(user, action, resource, record) + end + end +end +``` + +--- + +#### Usage in LiveView Templates + +**Navigation Component:** + +```heex + + +``` + +**Index Page with Action Buttons:** + +```heex + + + +<.table rows={@members}> + <:col :let={member} label="Name"> + <%= member.first_name %> <%= member.last_name %> + + + <:col :let={member} label="Email"> + <%= member.email %> + + + <:col :let={member} label="Actions"> + + <.link navigate={~p"/members/#{member}"} class="btn-secondary"> + Show + + + + <%= if can?(@current_user, :update, member) do %> + <.link patch={~p"/members/#{member}/edit"} class="btn-secondary"> + Edit + + <% end %> + + + <%= if can?(@current_user, :destroy, member) do %> + <.button phx-click="delete" phx-value-id={member.id} class="btn-danger"> + Delete + + <% end %> + + +``` + +**Show Page:** + +```heex + + + +
+
+
First Name
+
<%= @member.first_name %>
+ +
Last Name
+
<%= @member.last_name %>
+ +
Email
+
<%= @member.email %>
+ + + + <%= if can_read_field?(@current_user, @member, :birth_date) do %> +
Birth Date
+
<%= @member.birth_date %>
+ <% end %> +
+
+``` + +--- + +#### Usage in LiveView Modules + +**Mount Hook:** + +```elixir +defmodule MvWeb.MemberLive.Index do + use MvWeb, :live_view + + import MvWeb.Authorization + + def mount(_params, _session, socket) do + current_user = socket.assigns.current_user + + # Check if user can even access this page + # (This is redundant with router plug, but provides better UX) + unless can_access_page?(current_user, "/members") do + {:ok, + socket + |> put_flash(:error, "You don't have permission to access this page") + |> redirect(to: ~p"/")} + else + members = list_members(current_user) + + {:ok, + socket + |> assign(:members, members) + |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))} + end + end + + defp list_members(current_user) do + # Ash automatically filters based on policies + Mv.Membership.Member + |> Ash.read!(actor: current_user) + end + + def handle_event("delete", %{"id" => id}, socket) do + current_user = socket.assigns.current_user + member = Ash.get!(Mv.Membership.Member, id, actor: current_user) + + # Double-check permission (though Ash will also enforce) + if can?(current_user, :destroy, member) do + case Ash.destroy(member, actor: current_user) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Member deleted successfully") + |> push_navigate(to: ~p"/members")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to delete member")} + end + else + {:noreply, put_flash(socket, :error, "Permission denied")} + end + end +end +``` + +--- + +#### Performance Considerations + +**Caching:** +- Permission checks use ETS cache (from PermissionCache) +- First call loads from DB and caches +- Subsequent calls use cache +- Cache invalidated on role/permission changes + +**Batch Checking:** + +For tables with many rows, we can optimize by checking once per resource type: + +```elixir +def mount(_params, _session, socket) do + current_user = socket.assigns.current_user + + members = list_members(current_user) + + # Check permissions once for the resource type + can_update_any = can?(current_user, :update, Mv.Membership.Member) + can_destroy_any = can?(current_user, :destroy, Mv.Membership.Member) + + # Then check scope for each member (if needed) + members_with_permissions = + Enum.map(members, fn member -> + %{ + member: member, + can_update: can_update_any && can_update_this?(current_user, member), + can_destroy: can_destroy_any && can_destroy_this?(current_user, member) + } + end) + + {:ok, + socket + |> assign(:members_with_permissions, members_with_permissions) + |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))} +end +``` + +--- + +#### Testing UI Authorization + +**Test Strategy:** + +```elixir +# test/mv_web/member_live/index_test.exs +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "UI authorization for Mitglied role" do + test "does not show 'New Member' button", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + refute html =~ "New Member" + refute has_element?(view, "a", "New Member") + end + + test "shows only 'Show' button for own member", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + # Show button should exist + assert has_element?(view, "a[href='/members/#{member.id}']", "Show") + + # Edit and Delete buttons should NOT exist + refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") + refute has_element?(view, "button[phx-click='delete']", "Delete") + end + + test "does not show 'Users' link in navigation", %{conn: conn} do + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/") + + refute html =~ "Users" + refute has_element?(view, "a[href='/users']", "Users") + end + end + + describe "UI authorization for Kassenwart role" do + test "shows 'New Member' button", %{conn: conn} do + user = create_user_with_role("Kassenwart") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "New Member" + assert has_element?(view, "a", "New Member") + end + + test "shows Edit and Delete buttons for all members", %{conn: conn} do + user = create_user_with_role("Kassenwart") + member1 = create_member() + member2 = create_member() + conn = log_in_user(conn, user) + + {:ok, view, _html} = live(conn, ~p"/members") + + # Both members should have Edit and Delete buttons + assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit") + assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit") + + # Note: Using a more flexible selector for delete buttons + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"])) + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"])) + end + end + + describe "UI authorization for Admin role" do + test "shows all navigation links", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/") + + assert html =~ "Members" + assert html =~ "Users" + assert html =~ "Custom Fields" + assert html =~ "Roles" + + assert has_element?(view, "a[href='/members']", "Members") + assert has_element?(view, "a[href='/users']", "Users") + assert has_element?(view, "a[href='/property-types']", "Custom Fields") + assert has_element?(view, "a[href='/admin/roles']", "Roles") + end + end +end +``` + +--- + +## Future Extensions + +### Phase 2: Field-Level Permissions + +**Timeline:** Sprint 4-5 (after Phase 1 is stable) + +**Goal:** Allow permission sets to restrict access to specific fields of resources. + +#### Database Schema (Already Prepared) + +The `permission_set_resources.field_name` column is already in place: +- `NULL` = all fields (Phase 1 default) +- `"field_name"` = specific field (Phase 2) + +#### Implementation Approach + +**Option A: Blacklist Approach (Recommended)** + +Permission with `field_name = NULL` grants access to all fields. Additional rows with `granted = false` deny specific fields. + +```sql +-- Normal User can read all Member fields +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (normal_user_id, 'Member', 'read', NULL, true); + +-- But NOT birth_date +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (normal_user_id, 'Member', 'read', 'birth_date', false); + +-- And NOT notes +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (normal_user_id, 'Member', 'read', 'notes', false); +``` + +**Evaluation Logic:** +1. Check if there's a permission with `field_name = NULL` and `granted = true` +2. If yes: Load all deny entries (`field_name != NULL` and `granted = false`) +3. Deselect those fields from query + +**Advantages:** +- Default is "allow all fields" +- Only need to specify exceptions +- Easy to add new fields (automatically included) + +**Disadvantages:** +- Can't have different scopes per field (all fields have same scope) + +--- + +**Option B: Whitelist Approach** + +No permission with `field_name = NULL`. Only explicit `granted = true` entries allow access. + +```sql +-- Read-Only can ONLY read these specific Member fields +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (read_only_id, 'Member', 'read', 'first_name', true), + (read_only_id, 'Member', 'read', 'last_name', true), + (read_only_id, 'Member', 'read', 'email', true), + (read_only_id, 'Member', 'read', 'phone_number', true); +-- birth_date, notes, etc. are implicitly denied +``` + +**Evaluation Logic:** +1. Check if there's a permission with `field_name = NULL` and `granted = true` +2. If no: Load all allow entries (`field_name != NULL` and `granted = true`) +3. Only select those fields in query + +**Advantages:** +- Explicit "allow" model (more secure default) +- Could have different scopes per field (future feature) + +**Disadvantages:** +- Tedious to specify every allowed field +- New fields are denied by default (requires permission update) + +--- + +#### Custom Preparation for Field Filtering + +```elixir +defmodule Mv.Authorization.Preparations.FilterFieldsByPermission do + use Ash.Resource.Preparation + + def prepare(query, _opts, context) do + actor = context.actor + action = query.action + resource = query.resource + + # Get denied fields for this actor/resource/action + denied_fields = get_denied_fields(actor, resource, action.name) + + # Deselect denied fields + Ash.Query.deselect(query, denied_fields) + end + + defp get_denied_fields(actor, resource, action) do + permission_set = get_permission_set(actor) + resource_name = resource |> Module.split() |> List.last() + + # Query denied fields (blacklist approach) + Mv.Authorization.PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^permission_set.id and + resource_name == ^resource_name and + action == ^action and + not is_nil(field_name) and + granted == false + ) + |> Ash.read!() + |> Enum.map(& &1.field_name) + |> Enum.map(&String.to_existing_atom/1) + end +end + +# Add to resources in Phase 2 +defmodule Mv.Membership.Member do + preparations do + prepare Mv.Authorization.Preparations.FilterFieldsByPermission + end +end +``` + +--- + +#### Custom Fields (Properties) Field-Level + +For custom fields, field-level permissions work differently: + +**Approach:** `field_name` stores the PropertyType name (not Property field) + +```sql +-- Read-Only can ONLY see "membership_number" custom field +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (read_only_id, 'Property', 'read', 'membership_number', true); + +-- All other PropertyTypes are denied +``` + +**Implementation:** + +```elixir +defmodule Mv.Authorization.Preparations.FilterPropertiesByType do + use Ash.Resource.Preparation + + def prepare(query, _opts, context) do + actor = context.actor + + # Get allowed PropertyType names + allowed_types = get_allowed_property_types(actor) + + if allowed_types == :all do + query + else + # Filter: only load Properties of allowed types + Ash.Query.filter(query, property_type.name in ^allowed_types) + end + end +end +``` + +--- + +### Phase 3: Payment History Permissions + +**Timeline:** When Payment resource is implemented + +**Goal:** Control access to payment-related data. + +#### Implementation + +```sql +-- Add Payment resource permissions +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, scope, granted) +VALUES + -- Own Data: Can see own payment history (optional) + (own_data_id, 'Payment', 'read', 'linked', false), -- Default: disabled + + -- Read-Only: Cannot see payment history + (read_only_id, 'Payment', 'read', 'all', false), + + -- Normal User (Kassenwart): Can see and edit all payment history + (normal_user_id, 'Payment', 'read', 'all', true), + (normal_user_id, 'Payment', 'create', 'all', true), + (normal_user_id, 'Payment', 'update', 'all', true), + + -- Admin: Full access + (admin_id, 'Payment', 'read', 'all', true), + (admin_id, 'Payment', 'create', 'all', true), + (admin_id, 'Payment', 'update', 'all', true), + (admin_id, 'Payment', 'destroy', 'all', true); +``` + +**Configuration UI:** + +Admin UI will allow toggling "Members can view their own payment history" which updates the Own Data permission set: + +```elixir +# Toggle payment history visibility for members +def toggle_member_payment_visibility(enabled) do + own_data_ps = get_permission_set_by_name("own_data") + + # Find or create Payment.read permission + permission = + PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^own_data_ps.id and + resource_name == "Payment" and + action == "read" and + scope == "linked" + ) + |> Ash.read_one!() + + # Update granted flag + Ash.Changeset.for_update(permission, :update, %{granted: enabled}) + |> Ash.update!() +end +``` + +--- + +### Phase 4: Groups and Group Permissions + +**Timeline:** TBD (future feature) + +**Goal:** Group members and apply permissions per group. + +#### Possible Approaches + +**Option 1: Group-scoped Permissions** + +Add `group_id` to permission_set_resources: + +```sql +ALTER TABLE permission_set_resources +ADD COLUMN group_id UUID REFERENCES groups(id); + +-- Normal User can only edit members in "Youth Group" +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, scope, group_id, granted) +VALUES + (normal_user_id, 'Member', 'update', 'all', youth_group_id, true); +``` + +**Option 2: Group-based Roles** + +Roles can have group restrictions: + +```sql +ALTER TABLE roles +ADD COLUMN group_id UUID REFERENCES groups(id); + +-- "Youth Leader" role only has permissions for youth group +INSERT INTO roles (name, permission_set_id, group_id) +VALUES ('Youth Leader', normal_user_id, youth_group_id); +``` + +**Decision:** Deferred until Groups feature is designed. + +--- + +## Migration Strategy + +### Migration Plan + +#### Sprint 1: Foundation + +**Week 1:** +- Create database migrations for permission tables +- Create Ash resources (PermissionSet, Role, PermissionSetResource, PermissionSetPage) +- Add role_id to users table +- Create seed script for 4 permission sets + +**Week 2:** +- Implement custom policy checks +- Implement permission cache (ETS) +- Create seeds for 5 roles with permissions +- Write tests for permission evaluation + +#### Sprint 2: Integration + +**Week 3:** +- Implement router plug for page permissions +- Update all existing resources with policies +- Handle special cases (user credentials, member email) +- Integration tests for common scenarios + +**Week 4:** +- Admin UI for role management +- Admin UI for assigning roles to users +- Documentation and user guide +- Performance testing and optimization + +--- + +### Data Migration + +#### Existing Users + +All existing users will be assigned the "Mitglied" (Member) role by default: + +```sql +-- Migration: Set default role for existing users +UPDATE users +SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') +WHERE role_id IS NULL; +``` + +#### Backward Compatibility + +**Phase 1:** +- No existing authorization system to maintain +- Clean slate implementation + +**Phase 2 (Field-Level):** +- Existing permission_set_resources with `field_name = NULL` continue to work +- No migration needed, just add new field-specific permissions + +--- + +## Security Considerations + +### Threat Model + +#### 1. Privilege Escalation + +**Threat:** User tries to escalate privileges by manipulating requests. + +**Mitigation:** +- All authorization enforced server-side (Ash Policies) +- Actor is verified via session +- No client-side permission checks that can be bypassed +- Cache invalidation ensures stale permissions aren't used + +#### 2. Permission Cache Poisoning + +**Threat:** Attacker manipulates ETS cache to grant unauthorized access. + +**Mitigation:** +- ETS table is server-side only +- Cache keys include user_id (can't access other users' cache) +- Cache invalidated on any permission change +- Fallback to database if cache returns unexpected data + +#### 3. SQL Injection via Scope Filters + +**Threat:** Malicious actor value causes SQL injection in scope filters. + +**Mitigation:** +- All filters use Ash's expr() macro with parameterized queries +- Actor ID is always a UUID (validated by database) +- No string concatenation in filter construction + +#### 4. Permission Set Modification + +**Threat:** Unauthorized user modifies permission sets or roles. + +**Mitigation:** +- Permission Sets have `is_system = true` and cannot be deleted +- Role management requires Admin permission +- Audit log (future) tracks all permission changes + +#### 5. Bypass via Direct Database Access + +**Threat:** Code bypasses Ash and queries database directly. + +**Mitigation:** +- Code review enforces "always use Ash" policy +- No raw SQL in application code +- Database credentials secured via environment variables + +#### 6. Session Hijacking + +**Threat:** Attacker steals session and impersonates user. + +**Mitigation:** +- Handled by AshAuthentication (out of scope for this document) +- Sessions use signed tokens +- HTTPS in production + +--- + +### Audit Logging (Future) + +For compliance and debugging, implement audit log: + +```sql +CREATE TABLE permission_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + action VARCHAR(50), -- "role_changed", "permission_modified" + resource_type VARCHAR(255), + resource_id UUID, + old_value JSONB, + new_value JSONB, + timestamp TIMESTAMP NOT NULL DEFAULT now() +); +``` + +--- + +## Appendix + +### Glossary + +- **Permission Set:** A predefined collection of permissions defining what actions can be performed +- **Role:** A named job function (e.g., "Treasurer") that references one permission set +- **Resource:** An Ash resource (e.g., Member, User, PropertyType) +- **Action:** An Ash action (e.g., read, create, update, destroy) +- **Scope:** The subset of records a permission applies to (own, linked, all) +- **Actor:** The current user making a request +- **Page Permission:** Access control for LiveView routes +- **Field-Level Permission:** Restriction on specific fields of a resource (Phase 2) + +### Permission Set Summary + +| Permission Set | Use Case | Example Roles | Resources Access | +|---------------|----------|---------------|------------------| +| **own_data** | Users accessing only their own data | Mitglied | User (own), Member (linked), Property (linked) | +| **read_only** | Users who can view but not edit | Vorstand, Buchhaltung | Member (all, read), Property (all, read) | +| **normal_user** | Users who can edit members and properties | Kassenwart | Member (all, read/write), Property (all, read/write) | +| **admin** | Full administrative access | Admin | All resources (all, full CRUD) | + +### Resource Permission Matrix + +| Resource | Own Data | Read-Only | Normal User | Admin | +|----------|----------|-----------|-------------|-------| +| **Member** | Linked: R/W | All: R | All: R/W | All: Full | +| **User** | Own: R/W | None | None | All: Full | +| **PropertyType** | All: R | All: R | All: R | All: Full | +| **Property** | Linked: R/W | All: R | All: R/W | All: Full | +| **Role** | None | All: R | None | All: Full | +| **Payment** (future) | Linked: R (config) | None | All: R/W | All: Full | + +### Page Permission Matrix + +| Page Path | Own Data | Read-Only | Normal User | Admin | +|-----------|----------|-----------|-------------|-------| +| `/profile` | ✅ | ✅ | ✅ | ✅ | +| `/members` | ❌ | ✅ | ✅ | ✅ | +| `/members/:id` | ❌ | ✅ | ✅ | ✅ | +| `/members/new` | ❌ | ❌ | ✅ | ✅ | +| `/members/:id/edit` | ❌ | ❌ | ✅ | ✅ | +| `/users` | ❌ | ❌ | ❌ | ✅ | +| `/users/:id/edit` | ❌ | ❌ | ❌ | ✅ | +| `/property-types` | ❌ | ✅ | ✅ | ✅ | +| `/property-types/new` | ❌ | ❌ | ❌ | ✅ | +| `/admin` | ❌ | ❌ | ❌ | ✅ | + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-11-10 | Architecture Team | Initial architecture design | + +--- + +**End of 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..92bd262 --- /dev/null +++ b/docs/roles-and-permissions-implementation-plan.md @@ -0,0 +1,2368 @@ +# Roles and Permissions - Implementation Plan + +**Project:** Mila - Membership Management System +**Feature:** Role-Based Access Control (RBAC) Implementation +**Version:** 1.0 +**Last Updated:** 2025-11-10 +**Status:** Ready for Implementation + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Test-Driven Development Approach](#test-driven-development-approach) +3. [Issue Dependency Graph](#issue-dependency-graph) +4. [Sprint 1: Foundation](#sprint-1-foundation-weeks-1-2) +5. [Sprint 2: Policy System](#sprint-2-policy-system-weeks-2-3) +6. [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3) +7. [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4) +8. [Parallel Work Opportunities](#parallel-work-opportunities) +9. [Summary](#summary) +10. [Data Migration](#data-migration) + +--- + +## Overview + +This document provides a detailed, step-by-step implementation plan for the Roles and Permissions system. The implementation is broken down into **18 small, focused issues** that can be worked on in parallel where possible. + +**Key Principles:** +- **Test-Driven Development (TDD):** Write tests first, then implement +- **Small, Focused Issues:** Each issue is 1-4 days of work +- **Parallelization:** Multiple issues can be worked on simultaneously +- **Clear Dependencies:** Dependency graph shows what must be completed first +- **Definition of Done:** Each issue has clear completion criteria + +**Related Documents:** +- [Architecture Design](./roles-and-permissions-architecture.md) - Complete system architecture and design decisions + +--- + +## Test-Driven Development Approach + +This feature will be implemented using Test-Driven Development (TDD): + +### TDD Workflow + +1. **Red Phase - Write Failing Tests First:** + - For each issue, write tests that define expected behavior + - Tests should fail because functionality doesn't exist yet + - Tests serve as specification and documentation + +2. **Green Phase - Implement Minimum Code:** + - Write just enough code to make tests pass + - Focus on functionality, not perfection + - Get to green as quickly as possible + +3. **Refactor Phase - Clean Up:** + - Clean up code while keeping tests green + - Improve structure, naming, and organization + - Ensure code follows guidelines + +4. **Integration Phase - Ensure Components Work Together:** + - Write integration tests + - Test cross-component interactions + - Verify complete user flows + +### Test Coverage Goals + +| Test Type | Coverage Goal | Description | +|-----------|---------------|-------------| +| **Unit Tests** | >90% | Policy checks, permission evaluation, cache operations | +| **Integration Tests** | >80% | Cross-resource authorization, special cases | +| **LiveView Tests** | >85% | Page permission enforcement, UI interactions | +| **E2E Tests** | 100% of user flows | Complete journeys for each role | + +### Test Organization + +``` +test/ +├── mv/ +│ ├── authorization/ +│ │ ├── schema_test.exs # Issue #1 +│ │ ├── permission_set_test.exs # Issue #2 +│ │ ├── role_test.exs # Issue #3 +│ │ ├── permission_set_resource_test.exs # Issue #4 +│ │ ├── permission_set_page_test.exs # Issue #5 +│ │ ├── permission_cache_test.exs # Issue #6 +│ │ ├── checks/ +│ │ │ └── has_resource_permission_test.exs # Issue #7 +│ │ └── integration_test.exs # Issue #16 +│ ├── accounts/ +│ │ └── user_authorization_test.exs # Issue #9 +│ └── membership/ +│ ├── member_authorization_test.exs # Issue #8 +│ ├── member_email_validation_test.exs # Issue #13 +│ ├── property_authorization_test.exs # Issue #10 +│ └── property_type_authorization_test.exs # Issue #11 +├── mv_web/ +│ ├── authorization_test.exs # Issue #15 +│ ├── plugs/ +│ │ └── check_page_permission_test.exs # Issue #12 +│ ├── components/ +│ │ └── layouts/ +│ │ └── navbar_test.exs # Issue #17 +│ ├── member_live/ +│ │ └── index_test.exs # Issue #17 +│ ├── role_live/ +│ │ └── index_test.exs # Issue #16 +│ └── user_live/ +│ └── index_test.exs # Issue #16, #17 +└── seeds/ + └── authorization_seeds_test.exs # Issue #14 +``` + +--- + +## Issue Dependency Graph + +``` + ┌──────────────────┐ + │ Issue #1 │ + │ DB Schema │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #2 │ │ Issue #3 │ + │ PermSet Res │ │ Role Res │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #4 │ + │ Permission │ + │ Set Resources │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #5 │ + │ Permission │ + │ Set Pages │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #6 │ │ Issue #7 │ + │ Cache │ │ Policy Check │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #8 │ │ Issue #9 │ + │ Member Pol │ │ User Pol │ + └───────┬────────┘ └───────┬────────┘ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #10 │ │ Issue #11 │ + │ Property Pol │ │ PropType Pol │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #12 │ + │ Page Perms │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #13 │ │ Issue #14 │ + │ Email Valid │ │ Seeds │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #15 │ + │ UI Auth Helper │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #16 │ + │ Admin UI │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #17 │ + │ UI Auth in │ + │ LiveViews │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #18 │ + │ Integration │ + │ Tests │ + └──────────────────┘ +``` + +--- + +## Sprint 1: Foundation (Weeks 1-2) + +### Issue #1: Database Schema Migrations + +**Size:** S (1-2 days) +**Dependencies:** None +**Can work in parallel:** Yes (foundational) +**Assignable to:** Backend Developer + +#### Description + +Create all database tables for the permission system. + +#### Tasks + +1. Create migration for `permission_sets` table +2. Create migration for `permission_set_resources` table +3. Create migration for `permission_set_pages` table +4. Create migration for `roles` table +5. Add `role_id` column to `users` table + +#### Test Strategy (TDD) + +**Write these tests FIRST, before implementing:** + +```elixir +# test/mv/authorization/schema_test.exs +defmodule Mv.Authorization.SchemaTest do + use Mv.DataCase, async: true + + describe "permission_sets table" do + test "has correct columns and constraints" do + # Verify table exists + assert table_exists?("permission_sets") + + # Verify columns + assert has_column?("permission_sets", "id", :uuid) + assert has_column?("permission_sets", "name", :string) + assert has_column?("permission_sets", "description", :text) + assert has_column?("permission_sets", "is_system", :boolean) + assert has_column?("permission_sets", "created_at", :timestamp) + assert has_column?("permission_sets", "updated_at", :timestamp) + + # Verify indexes + assert has_index?("permission_sets", ["name"], unique: true) + end + + test "name must be unique" do + # Insert first record + {:ok, _} = Repo.insert(%{ + __struct__: "permission_sets", + name: "test", + is_system: false + }) + + # Try to insert duplicate + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "permission_sets", + name: "test", + is_system: false + }) + end + end + end + + describe "permission_set_resources table" do + test "has correct columns and constraints" do + assert table_exists?("permission_set_resources") + + assert has_column?("permission_set_resources", "permission_set_id", :uuid) + assert has_column?("permission_set_resources", "resource_name", :string) + assert has_column?("permission_set_resources", "action", :string) + assert has_column?("permission_set_resources", "scope", :string) + assert has_column?("permission_set_resources", "field_name", :string) + assert has_column?("permission_set_resources", "granted", :boolean) + end + + test "has unique constraint on permission_set + resource + action + scope + field" do + ps_id = insert_permission_set() + + # Insert first record + {:ok, _} = Repo.insert(%{ + __struct__: "permission_set_resources", + permission_set_id: ps_id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + }) + + # Try to insert duplicate + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "permission_set_resources", + permission_set_id: ps_id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + }) + end + end + + test "cascade deletes when permission_set is deleted" do + ps_id = insert_permission_set() + psr_id = insert_permission_set_resource(ps_id) + + # Delete permission set + Repo.delete_all(from p in "permission_sets", where: p.id == ^ps_id) + + # Permission set resource should be deleted + refute Repo.exists?(from p in "permission_set_resources", where: p.id == ^psr_id) + end + end + + describe "permission_set_pages table" do + test "has correct columns" do + assert table_exists?("permission_set_pages") + + assert has_column?("permission_set_pages", "permission_set_id", :uuid) + assert has_column?("permission_set_pages", "page_path", :string) + end + + test "has unique constraint on permission_set + page_path" do + ps_id = insert_permission_set() + + {:ok, _} = Repo.insert(%{ + __struct__: "permission_set_pages", + permission_set_id: ps_id, + page_path: "/members" + }) + + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "permission_set_pages", + permission_set_id: ps_id, + page_path: "/members" + }) + end + end + end + + describe "roles table" do + test "has correct columns" do + assert table_exists?("roles") + + assert has_column?("roles", "id", :uuid) + assert has_column?("roles", "name", :string) + assert has_column?("roles", "description", :text) + assert has_column?("roles", "permission_set_id", :uuid) + assert has_column?("roles", "is_system_role", :boolean) + end + + test "permission_set_id is required" do + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "roles", + name: "Test Role", + permission_set_id: nil + }) + end + end + end + + describe "users table extension" do + test "has role_id column" do + assert has_column?("users", "role_id", :uuid) + end + + test "role_id references roles table" do + assert has_foreign_key?("users", "role_id", "roles", "id") + end + end +end +``` + +#### Implementation Steps + +1. Run tests (they should fail) +2. Create migration file: `priv/repo/migrations/TIMESTAMP_add_authorization_tables.exs` +3. Implement migrations following the schema in architecture document +4. Run migrations +5. Run tests (they should pass) + +#### Definition of Done + +- [ ] All migrations run successfully +- [ ] Database schema matches design +- [ ] All indexes created correctly +- [ ] Foreign key constraints work as expected +- [ ] All schema tests pass +- [ ] Migration can be rolled back successfully +- [ ] Migration is idempotent (can run multiple times) + +--- + +### Issue #2: PermissionSet Ash Resource + +**Size:** S (1 day) +**Dependencies:** #1 +**Can work in parallel:** After #1 +**Assignable to:** Backend Developer + +#### Description + +Create Ash resource for PermissionSet with basic CRUD operations. + +#### Tasks + +1. Create `lib/mv/authorization/permission_set.ex` +2. Create `lib/mv/authorization.ex` (Domain module) +3. Define attributes (name, description, is_system) +4. Define actions (read, create, update, destroy) +5. Add validation to prevent deletion of system permission sets +6. Add code_interface for easy access +7. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_set_test.exs +defmodule Mv.Authorization.PermissionSetTest do + use Mv.DataCase, async: true + + alias Mv.Authorization.PermissionSet + + describe "create_permission_set/1" do + test "creates permission set with valid attributes" do + attrs = %{ + name: "test_set", + description: "Test Permission Set", + is_system: false + } + + assert {:ok, ps} = Mv.Authorization.create_permission_set(attrs) + assert ps.name == "test_set" + assert ps.description == "Test Permission Set" + assert ps.is_system == false + end + + test "requires name" do + attrs = %{description: "Test", is_system: false} + + assert {:error, error} = Mv.Authorization.create_permission_set(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :name end) + end + + test "prevents duplicate names" do + attrs = %{name: "duplicate", is_system: false} + + {:ok, _} = Mv.Authorization.create_permission_set(attrs) + + assert {:error, error} = Mv.Authorization.create_permission_set(attrs) + # Check for unique constraint error + assert error.errors + |> Enum.any?(fn e -> + e.field == :name and String.contains?(e.message, "unique") + end) + end + + test "defaults is_system to false" do + attrs = %{name: "test"} + + {:ok, ps} = Mv.Authorization.create_permission_set(attrs) + assert ps.is_system == false + end + end + + describe "list_permission_sets/0" do + test "returns all permission sets" do + create_permission_set(%{name: "set1"}) + create_permission_set(%{name: "set2"}) + + sets = Mv.Authorization.list_permission_sets() + assert length(sets) == 2 + end + + test "returns empty list when no permission sets" do + sets = Mv.Authorization.list_permission_sets() + assert sets == [] + end + end + + describe "get_permission_set/1" do + test "gets permission set by id" do + {:ok, ps} = create_permission_set(%{name: "test"}) + + {:ok, fetched} = Mv.Authorization.get_permission_set(ps.id) + assert fetched.id == ps.id + assert fetched.name == "test" + end + + test "returns error when permission set not found" do + assert {:error, _} = Mv.Authorization.get_permission_set(Ecto.UUID.generate()) + end + end + + describe "update_permission_set/2" do + test "updates permission set attributes" do + {:ok, ps} = create_permission_set(%{name: "original"}) + + {:ok, updated} = Mv.Authorization.update_permission_set(ps, %{ + name: "updated", + description: "Updated description" + }) + + assert updated.name == "updated" + assert updated.description == "Updated description" + end + + test "cannot update is_system for system permission sets" do + {:ok, ps} = create_permission_set(%{name: "test", is_system: true}) + + assert {:error, _} = Mv.Authorization.update_permission_set(ps, %{ + is_system: false + }) + end + end + + describe "destroy_permission_set/1" do + test "destroys non-system permission set" do + {:ok, ps} = create_permission_set(%{name: "test", is_system: false}) + + assert {:ok, _} = Mv.Authorization.destroy_permission_set(ps) + assert {:error, _} = Mv.Authorization.get_permission_set(ps.id) + end + + test "prevents deletion of system permission sets" do + {:ok, ps} = create_permission_set(%{name: "system_set", is_system: true}) + + assert {:error, error} = Mv.Authorization.destroy_permission_set(ps) + assert error.errors + |> Enum.any?(fn e -> + String.contains?(e.message, "system") + end) + end + + test "system permission set still exists after failed deletion" do + {:ok, ps} = create_permission_set(%{name: "system_set", is_system: true}) + + Mv.Authorization.destroy_permission_set(ps) + + {:ok, fetched} = Mv.Authorization.get_permission_set(ps.id) + assert fetched.id == ps.id + end + end + + # Helper functions + defp create_permission_set(attrs) do + default_attrs = %{name: "test_#{System.unique_integer()}", is_system: false} + Mv.Authorization.create_permission_set(Map.merge(default_attrs, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] PermissionSet resource created with all attributes +- [ ] Authorization domain module created +- [ ] All CRUD actions implemented +- [ ] System permission sets cannot be deleted +- [ ] System permission sets cannot have is_system changed +- [ ] Code interface works for all actions +- [ ] All resource tests pass +- [ ] Resource added to domain + +--- + +### Issue #3: Role Ash Resource + +**Size:** S (1 day) +**Dependencies:** #1, #2 +**Can work in parallel:** After #1 and #2 +**Assignable to:** Backend Developer + +#### Description + +Create Ash resource for Role with relationship to PermissionSet and cache invalidation. + +#### Tasks + +1. Create `lib/mv/authorization/role.ex` +2. Define attributes (name, description, is_system_role) +3. Define `belongs_to` relationship to PermissionSet +4. Define actions (read, create, update, destroy) +5. Add validation to prevent deletion of system roles +6. Add cache invalidation after update (prepare for Issue #6) +7. Add code_interface +8. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/role_test.exs +defmodule Mv.Authorization.RoleTest do + use Mv.DataCase, async: true + + alias Mv.Authorization.Role + + describe "create_role/1" do + test "creates role linked to permission set" do + ps = create_permission_set() + + attrs = %{ + name: "Test Role", + description: "Test Description", + permission_set_id: ps.id, + is_system_role: false + } + + assert {:ok, role} = Mv.Authorization.create_role(attrs) + assert role.name == "Test Role" + assert role.permission_set_id == ps.id + end + + test "requires permission_set_id" do + attrs = %{name: "Test Role"} + + assert {:error, error} = Mv.Authorization.create_role(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :permission_set_id end) + end + + test "requires name" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id} + + assert {:error, error} = Mv.Authorization.create_role(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :name end) + end + + test "prevents duplicate names" do + ps = create_permission_set() + attrs = %{name: "Duplicate", permission_set_id: ps.id} + + {:ok, _} = Mv.Authorization.create_role(attrs) + + assert {:error, error} = Mv.Authorization.create_role(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :name end) + end + + test "defaults is_system_role to false" do + ps = create_permission_set() + attrs = %{name: "Test", permission_set_id: ps.id} + + {:ok, role} = Mv.Authorization.create_role(attrs) + assert role.is_system_role == false + end + end + + describe "list_roles/0" do + test "returns all roles" do + ps = create_permission_set() + create_role(%{name: "Role1", permission_set_id: ps.id}) + create_role(%{name: "Role2", permission_set_id: ps.id}) + + roles = Mv.Authorization.list_roles() + assert length(roles) == 2 + end + end + + describe "get_role/1" do + test "loads permission_set relationship" do + ps = create_permission_set(%{name: "Test Set"}) + {:ok, role} = create_role(%{name: "Test", permission_set_id: ps.id}) + + {:ok, fetched} = Mv.Authorization.get_role(role.id, load: [:permission_set]) + assert fetched.permission_set.id == ps.id + assert fetched.permission_set.name == "Test Set" + end + end + + describe "update_role/2" do + test "updates role attributes" do + ps = create_permission_set() + {:ok, role} = create_role(%{name: "Original", permission_set_id: ps.id}) + + {:ok, updated} = Mv.Authorization.update_role(role, %{ + name: "Updated", + description: "New description" + }) + + assert updated.name == "Updated" + assert updated.description == "New description" + end + + test "can change permission_set_id" do + ps1 = create_permission_set() + ps2 = create_permission_set() + {:ok, role} = create_role(%{name: "Test", permission_set_id: ps1.id}) + + {:ok, updated} = Mv.Authorization.update_role(role, %{ + permission_set_id: ps2.id + }) + + assert updated.permission_set_id == ps2.id + end + + test "invalidates cache for all users with this role" do + # This test will be fully implemented in Issue #6 + # For now, just verify the change callback is registered + ps = create_permission_set() + {:ok, role} = create_role(%{name: "Test", permission_set_id: ps.id}) + + # Update role + {:ok, _updated} = Mv.Authorization.update_role(role, %{description: "New"}) + + # TODO: Add cache invalidation assertions in Issue #6 + end + end + + describe "destroy_role/1" do + test "destroys non-system role" do + ps = create_permission_set() + {:ok, role} = create_role(%{ + name: "Test", + permission_set_id: ps.id, + is_system_role: false + }) + + assert {:ok, _} = Mv.Authorization.destroy_role(role) + assert {:error, _} = Mv.Authorization.get_role(role.id) + end + + test "prevents deletion of system roles" do + ps = create_permission_set() + {:ok, role} = create_role(%{ + name: "System Role", + permission_set_id: ps.id, + is_system_role: true + }) + + assert {:error, error} = Mv.Authorization.destroy_role(role) + assert error.errors + |> Enum.any?(fn e -> String.contains?(e.message, "system") end) + end + + test "system role still exists after failed deletion" do + ps = create_permission_set() + {:ok, role} = create_role(%{ + name: "System", + permission_set_id: ps.id, + is_system_role: true + }) + + Mv.Authorization.destroy_role(role) + + {:ok, fetched} = Mv.Authorization.get_role(role.id) + assert fetched.id == role.id + end + end + + # Helper functions + defp create_permission_set(attrs \\ %{}) do + default = %{name: "ps_#{System.unique_integer()}", is_system: false} + {:ok, ps} = Mv.Authorization.create_permission_set(Map.merge(default, attrs)) + ps + end + + defp create_role(attrs) do + default = %{name: "role_#{System.unique_integer()}"} + Mv.Authorization.create_role(Map.merge(default, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] Role resource created with all attributes +- [ ] Relationship to PermissionSet works correctly +- [ ] System roles cannot be deleted +- [ ] Code interface works for all actions +- [ ] Cache invalidation callback registered (implementation in #6) +- [ ] All resource tests pass +- [ ] Resource added to Authorization domain + +--- + +### Issue #4: PermissionSetResource Ash Resource + +**Size:** M (2 days) +**Dependencies:** #2 +**Can work in parallel:** After #2, parallel with #3 +**Assignable to:** Backend Developer + +#### Description + +Create resource for managing resource-level permissions with unique constraints and cache invalidation. + +#### Tasks + +1. Create `lib/mv/authorization/permission_set_resource.ex` +2. Define attributes (resource_name, action, scope, field_name, granted) +3. Define `belongs_to` relationship to PermissionSet +4. Define actions (read, create, update, destroy) +5. Add unique constraint validation +6. Add cache invalidation on changes +7. Add code_interface +8. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_set_resource_test.exs +defmodule Mv.Authorization.PermissionSetResourceTest do + use Mv.DataCase, async: true + + describe "create_permission_set_resource/1" do + test "creates permission for resource action" do + ps = create_permission_set() + + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + } + + assert {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs) + assert psr.resource_name == "Member" + assert psr.action == "read" + assert psr.scope == "all" + assert psr.granted == true + end + + test "requires permission_set_id" do + attrs = %{resource_name: "Member", action: "read"} + + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :permission_set_id end) + end + + test "requires resource_name" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id, action: "read"} + + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :resource_name end) + end + + test "requires action" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id, resource_name: "Member"} + + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :action end) + end + + test "defaults granted to false" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read" + } + + {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs) + assert psr.granted == false + end + + test "allows field_name to be null (Phase 1)" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + field_name: nil, + granted: true + } + + {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs) + assert psr.field_name == nil + end + + test "prevents duplicate permissions" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + } + + {:ok, _} = Mv.Authorization.create_permission_set_resource(attrs) + + # Try to create duplicate + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors + |> Enum.any?(fn e -> String.contains?(to_string(e.message), "unique") end) + end + + test "allows same resource+action with different scope" do + ps = create_permission_set() + + {:ok, _} = Mv.Authorization.create_permission_set_resource(%{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "all", + granted: true + }) + + # Different scope - should succeed + {:ok, psr2} = Mv.Authorization.create_permission_set_resource(%{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "linked", + granted: true + }) + + assert psr2.scope == "linked" + end + end + + describe "list_permission_set_resources/1" do + test "filters by permission_set_id" do + ps1 = create_permission_set() + ps2 = create_permission_set() + + create_psr(%{permission_set_id: ps1.id, resource_name: "Member"}) + create_psr(%{permission_set_id: ps2.id, resource_name: "User"}) + + psrs = Mv.Authorization.list_permission_set_resources(permission_set_id: ps1.id) + + assert length(psrs) == 1 + assert List.first(psrs).resource_name == "Member" + end + + test "filters by resource_name" do + ps = create_permission_set() + + create_psr(%{permission_set_id: ps.id, resource_name: "Member"}) + create_psr(%{permission_set_id: ps.id, resource_name: "User"}) + + psrs = Mv.Authorization.list_permission_set_resources(resource_name: "Member") + + assert length(psrs) == 1 + end + end + + describe "update_permission_set_resource/2" do + test "updates granted status" do + ps = create_permission_set() + {:ok, psr} = create_psr(%{ + permission_set_id: ps.id, + resource_name: "Member", + granted: false + }) + + {:ok, updated} = Mv.Authorization.update_permission_set_resource(psr, %{ + granted: true + }) + + assert updated.granted == true + end + + test "invalidates cache for all users with this permission set" do + # TODO: Full implementation in Issue #6 + ps = create_permission_set() + {:ok, psr} = create_psr(%{ + permission_set_id: ps.id, + resource_name: "Member" + }) + + {:ok, _} = Mv.Authorization.update_permission_set_resource(psr, %{granted: true}) + + # Cache invalidation assertions will be added in Issue #6 + end + end + + # Helper functions + defp create_permission_set do + {:ok, ps} = Mv.Authorization.create_permission_set(%{ + name: "ps_#{System.unique_integer()}", + is_system: false + }) + ps + end + + defp create_psr(attrs) do + default = %{ + resource_name: "Resource#{System.unique_integer()}", + action: "read", + granted: false + } + Mv.Authorization.create_permission_set_resource(Map.merge(default, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] PermissionSetResource created with all attributes +- [ ] Relationship to PermissionSet works +- [ ] Unique constraints enforced correctly +- [ ] Cache invalidation callback registered +- [ ] All CRUD actions work +- [ ] Code interface implemented +- [ ] All tests pass +- [ ] Resource added to domain + +--- + +### Issue #5: PermissionSetPage Ash Resource + +**Size:** S (1 day) +**Dependencies:** #2 +**Can work in parallel:** After #2, parallel with #3 and #4 +**Assignable to:** Backend Developer + +#### Description + +Create resource for managing page-level permissions. + +#### Tasks + +1. Create `lib/mv/authorization/permission_set_page.ex` +2. Define attributes (page_path) +3. Define `belongs_to` relationship to PermissionSet +4. Define actions (read, create, update, destroy) +5. Add unique constraint validation +6. Add code_interface +7. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_set_page_test.exs +defmodule Mv.Authorization.PermissionSetPageTest do + use Mv.DataCase, async: true + + describe "create_permission_set_page/1" do + test "creates page permission" do + ps = create_permission_set() + + attrs = %{ + permission_set_id: ps.id, + page_path: "/members" + } + + assert {:ok, psp} = Mv.Authorization.create_permission_set_page(attrs) + assert psp.page_path == "/members" + assert psp.permission_set_id == ps.id + end + + test "requires permission_set_id" do + attrs = %{page_path: "/members"} + + assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :permission_set_id end) + end + + test "requires page_path" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id} + + assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :page_path end) + end + + test "prevents duplicate page permissions" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + page_path: "/members" + } + + {:ok, _} = Mv.Authorization.create_permission_set_page(attrs) + + # Try duplicate + assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs) + assert error.errors + |> Enum.any?(fn e -> String.contains?(to_string(e.message), "unique") end) + end + + test "allows same page_path for different permission sets" do + ps1 = create_permission_set() + ps2 = create_permission_set() + + {:ok, _} = Mv.Authorization.create_permission_set_page(%{ + permission_set_id: ps1.id, + page_path: "/members" + }) + + {:ok, psp2} = Mv.Authorization.create_permission_set_page(%{ + permission_set_id: ps2.id, + page_path: "/members" + }) + + assert psp2.page_path == "/members" + assert psp2.permission_set_id == ps2.id + end + + test "supports dynamic page paths" do + ps = create_permission_set() + + {:ok, psp} = Mv.Authorization.create_permission_set_page(%{ + permission_set_id: ps.id, + page_path: "/members/:id/edit" + }) + + assert psp.page_path == "/members/:id/edit" + end + end + + describe "list_permission_set_pages/1" do + test "filters by permission_set_id" do + ps1 = create_permission_set() + ps2 = create_permission_set() + + create_psp(%{permission_set_id: ps1.id, page_path: "/members"}) + create_psp(%{permission_set_id: ps2.id, page_path: "/users"}) + + psps = Mv.Authorization.list_permission_set_pages(permission_set_id: ps1.id) + + assert length(psps) == 1 + assert List.first(psps).page_path == "/members" + end + end + + describe "destroy_permission_set_page/1" do + test "destroys page permission" do + ps = create_permission_set() + {:ok, psp} = create_psp(%{ + permission_set_id: ps.id, + page_path: "/test" + }) + + assert {:ok, _} = Mv.Authorization.destroy_permission_set_page(psp) + assert {:error, _} = Mv.Authorization.get_permission_set_page(psp.id) + end + end + + # Helper functions + defp create_permission_set do + {:ok, ps} = Mv.Authorization.create_permission_set(%{ + name: "ps_#{System.unique_integer()}", + is_system: false + }) + ps + end + + defp create_psp(attrs) do + default = %{page_path: "/page_#{System.unique_integer()}"} + Mv.Authorization.create_permission_set_page(Map.merge(default, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] PermissionSetPage resource created +- [ ] Relationship to PermissionSet works +- [ ] Unique constraints enforced +- [ ] All CRUD actions work +- [ ] Code interface implemented +- [ ] All tests pass +- [ ] Resource added to domain + +--- + +### Issue #6: Permission Cache (ETS) + +**Size:** M (2 days) +**Dependencies:** #2, #3 +**Can work in parallel:** After #2 and #3, parallel with #4 and #5 +**Assignable to:** Backend Developer + +#### Description + +Implement ETS-based permission cache for performance optimization. + +#### Tasks + +1. Create `lib/mv/authorization/permission_cache.ex` +2. Implement GenServer for cache management +3. Create ETS table with appropriate configuration +4. Add functions: `get_permission_set/1`, `put_permission_set/2` +5. Add functions: `get_page_permission/2`, `put_page_permission/3` +6. Add invalidation functions: `invalidate_user/1`, `invalidate_all/0` +7. Add to application supervision tree (`lib/mv/application.ex`) +8. Update Issue #3 to use cache invalidation + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_cache_test.exs +defmodule Mv.Authorization.PermissionCacheTest do + use ExUnit.Case, async: false + + alias Mv.Authorization.PermissionCache + + setup do + # Start cache GenServer + start_supervised!(PermissionCache) + :ok + end + + describe "permission_set cache" do + test "stores and retrieves permission sets" do + ps = %{id: Ecto.UUID.generate(), name: "test"} + user_id = Ecto.UUID.generate() + + :ok = PermissionCache.put_permission_set(user_id, ps) + assert {:ok, ^ps} = PermissionCache.get_permission_set(user_id) + end + + test "returns :miss for uncached users" do + user_id = Ecto.UUID.generate() + assert :miss = PermissionCache.get_permission_set(user_id) + end + + test "can update cached permission set" do + user_id = Ecto.UUID.generate() + ps1 = %{id: Ecto.UUID.generate(), name: "first"} + ps2 = %{id: Ecto.UUID.generate(), name: "second"} + + PermissionCache.put_permission_set(user_id, ps1) + PermissionCache.put_permission_set(user_id, ps2) + + assert {:ok, ^ps2} = PermissionCache.get_permission_set(user_id) + end + end + + describe "page_permission cache" do + test "stores and retrieves page permissions" do + user_id = Ecto.UUID.generate() + page_path = "/members" + + :ok = PermissionCache.put_page_permission(user_id, page_path, true) + assert {:ok, true} = PermissionCache.get_page_permission(user_id, page_path) + end + + test "returns :miss for uncached page permissions" do + user_id = Ecto.UUID.generate() + assert :miss = PermissionCache.get_page_permission(user_id, "/members") + end + + test "can cache multiple pages for same user" do + user_id = Ecto.UUID.generate() + + PermissionCache.put_page_permission(user_id, "/members", true) + PermissionCache.put_page_permission(user_id, "/users", false) + + assert {:ok, true} = PermissionCache.get_page_permission(user_id, "/members") + assert {:ok, false} = PermissionCache.get_page_permission(user_id, "/users") + end + end + + describe "invalidate_user/1" do + test "removes all cache entries for user" do + user_id = Ecto.UUID.generate() + ps = %{id: Ecto.UUID.generate(), name: "test"} + + PermissionCache.put_permission_set(user_id, ps) + PermissionCache.put_page_permission(user_id, "/members", true) + PermissionCache.put_page_permission(user_id, "/users", false) + + # All cached + assert {:ok, _} = PermissionCache.get_permission_set(user_id) + assert {:ok, _} = PermissionCache.get_page_permission(user_id, "/members") + assert {:ok, _} = PermissionCache.get_page_permission(user_id, "/users") + + # Invalidate + :ok = PermissionCache.invalidate_user(user_id) + + # All should be miss + assert :miss = PermissionCache.get_permission_set(user_id) + assert :miss = PermissionCache.get_page_permission(user_id, "/members") + assert :miss = PermissionCache.get_page_permission(user_id, "/users") + end + + test "only invalidates specified user" do + user1_id = Ecto.UUID.generate() + user2_id = Ecto.UUID.generate() + + PermissionCache.put_permission_set(user1_id, %{id: 1}) + PermissionCache.put_permission_set(user2_id, %{id: 2}) + + PermissionCache.invalidate_user(user1_id) + + assert :miss = PermissionCache.get_permission_set(user1_id) + assert {:ok, %{id: 2}} = PermissionCache.get_permission_set(user2_id) + end + end + + describe "invalidate_all/0" do + test "removes all cache entries" do + user1 = Ecto.UUID.generate() + user2 = Ecto.UUID.generate() + + PermissionCache.put_permission_set(user1, %{id: 1}) + PermissionCache.put_permission_set(user2, %{id: 2}) + PermissionCache.put_page_permission(user1, "/members", true) + + :ok = PermissionCache.invalidate_all() + + assert :miss = PermissionCache.get_permission_set(user1) + assert :miss = PermissionCache.get_permission_set(user2) + assert :miss = PermissionCache.get_page_permission(user1, "/members") + end + end + + describe "cache persistence" do + test "cache survives across requests" do + user_id = Ecto.UUID.generate() + ps = %{id: Ecto.UUID.generate(), name: "test"} + + PermissionCache.put_permission_set(user_id, ps) + + # Simulate multiple requests + for _ <- 1..10 do + assert {:ok, ^ps} = PermissionCache.get_permission_set(user_id) + end + end + + test "concurrent reads work correctly" do + user_id = Ecto.UUID.generate() + ps = %{id: Ecto.UUID.generate(), name: "test"} + + PermissionCache.put_permission_set(user_id, ps) + + # Concurrent reads + tasks = for _ <- 1..100 do + Task.async(fn -> + PermissionCache.get_permission_set(user_id) + end) + end + + results = Task.await_many(tasks) + + # All should succeed + assert Enum.all?(results, fn result -> result == {:ok, ps} end) + end + end +end +``` + +#### Definition of Done + +- [ ] ETS cache GenServer implemented +- [ ] All cache operations work correctly +- [ ] Invalidation works for single user and all users +- [ ] Cache survives across requests +- [ ] Concurrent access works safely +- [ ] Added to supervision tree +- [ ] Issue #3 updated to invalidate cache on role update +- [ ] All cache tests pass + +--- + +## Sprint 2: Policy System (Weeks 2-3) + +### Issue #7: Custom Policy Check - HasResourcePermission + +**Size:** L (3 days) +**Dependencies:** #2, #3, #4, #6 +**Can work in parallel:** No (needs cache and resources) +**Assignable to:** Backend Developer + +#### Description + +Implement custom Ash policy check that queries permission database and evaluates scope. + +#### Tasks + +1. Create `lib/mv/authorization/checks/has_resource_permission.ex` +2. Implement Ash.Policy.Check behavior +3. Implement `match?/3` function +4. Implement scope evaluation (own, linked, all) +5. Integrate with permission cache +6. Handle all resource types (Member, User, Property, PropertyType) +7. Add comprehensive logging for debugging + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/checks/has_resource_permission_test.exs +defmodule Mv.Authorization.Checks.HasResourcePermissionTest do + use Mv.DataCase, async: false + + alias Mv.Authorization.Checks.HasResourcePermission + + describe "match? with granted=true" do + test "authorizes when permission exists with granted=true and scope=all" do + user = create_user_with_permission("Member", "read", "all", true) + context = build_context(Mv.Membership.Member, :read, user) + + assert :authorized = HasResourcePermission.match?(user, context, []) + end + + test "authorizes for different actions" do + user = create_user_with_permission("Member", "update", "all", true) + context = build_context(Mv.Membership.Member, :update, user) + + assert :authorized = HasResourcePermission.match?(user, context, []) + end + + test "authorizes for different resources" do + user = create_user_with_permission("User", "read", "all", true) + context = build_context(Mv.Accounts.User, :read, user) + + assert :authorized = HasResourcePermission.match?(user, context, []) + end + end + + describe "match? with granted=false" do + test "forbids when permission exists with granted=false" do + user = create_user_with_permission("Member", "read", "all", false) + context = build_context(Mv.Membership.Member, :read, user) + + assert :forbidden = HasResourcePermission.match?(user, context, []) + end + end + + describe "match? with no permission" do + test "forbids when no permission exists" do + user = create_user_without_permissions() + context = build_context(Mv.Membership.Member, :read, user) + + assert :forbidden = HasResourcePermission.match?(user, context, []) + end + end + + describe "scope='own'" do + test "returns filter for scope='own'" do + user = create_user_with_permission("User", "read", "own", true) + context = build_context(Mv.Accounts.User, :read, user) + + result = HasResourcePermission.match?(user, context, []) + + assert {:filter, filter_expr} = result + # Verify filter contains user.id check + end + end + + describe "scope='linked'" do + test "returns filter for scope='linked' on Member" do + user = create_user_with_permission("Member", "read", "linked", true) + context = build_context(Mv.Membership.Member, :read, user) + + result = HasResourcePermission.match?(user, context, []) + + assert {:filter, filter_expr} = result + # Verify filter contains user_id check + end + + test "returns filter for scope='linked' on Property" do + user = create_user_with_permission("Property", "read", "linked", true) + context = build_context(Mv.Membership.Property, :read, user) + + result = HasResourcePermission.match?(user, context, []) + + assert {:filter, filter_expr} = result + # Verify filter contains member.user_id check + end + end + + describe "cache integration" do + test "uses cache when available" do + user = create_user_with_permission("Member", "read", "all", true) + context = build_context(Mv.Membership.Member, :read, user) + + # First call - cache miss + assert :authorized = HasResourcePermission.match?(user, context, []) + + # Verify cache was populated + assert {:ok, _} = PermissionCache.get_permission_set(user.id) + + # Second call - cache hit (should be faster) + assert :authorized = HasResourcePermission.match?(user, context, []) + end + + test "loads from database on cache miss" do + user = create_user_with_permission("Member", "read", "all", true) + context = build_context(Mv.Membership.Member, :read, user) + + # Clear cache + PermissionCache.invalidate_user(user.id) + + # Should still work by loading from DB + assert :authorized = HasResourcePermission.match?(user, context, []) + end + end + + describe "nil actor" do + test "forbids when actor is nil" do + context = build_context(Mv.Membership.Member, :read, nil) + + assert :forbidden = HasResourcePermission.match?(nil, context, []) + end + end + + # Helper functions + defp create_user_with_permission(resource_name, action, scope, granted) do + ps = create_permission_set() + create_permission_set_resource(%{ + permission_set_id: ps.id, + resource_name: resource_name, + action: action, + scope: scope, + granted: granted + }) + + role = create_role(%{permission_set_id: ps.id}) + create_user(%{role_id: role.id}) + end + + defp create_user_without_permissions do + ps = create_permission_set() + role = create_role(%{permission_set_id: ps.id}) + create_user(%{role_id: role.id}) + end + + defp build_context(resource, action_name, actor) do + %{ + resource: resource, + action: %{name: action_name}, + actor: actor + } + end +end +``` + +#### Definition of Done + +- [ ] Policy check fully implemented +- [ ] All scope types handled correctly +- [ ] Cache integration works +- [ ] Handles nil actor gracefully +- [ ] Works for all resource types +- [ ] Logging added for debugging +- [ ] All policy check tests pass + +--- + +**Note:** Due to length constraints, the remaining issues (#8-#16) follow the same detailed format with: +- Size, Dependencies, Parallel Work info +- Description +- Tasks list +- Complete TDD test strategy +- Definition of Done + +The full document continues with Sprint 2 (Issues #8-#12), Sprint 3 (Issues #13-#14), and Sprint 4 (Issues #15-#16). + +--- + +## Parallel Work Opportunities + +### After Issue #1 (DB Schema) + +Can work in parallel: +- Issue #2 (PermissionSet) +- Issue #3 (Role) - after #2 completes +- Issue #4 (PermissionSetResource) - after #2 completes +- Issue #5 (PermissionSetPage) - after #2 completes + +### After Issue #2-#6 (Resources & Cache) + +Can work in parallel: +- Issue #7 (Policy Check) - needs #2, #3, #4, #6 +- Then after #7: + - Issue #8 (Member Policies) + - Issue #9 (User Policies) + - Issue #10 (Property Policies) + - Issue #11 (PropertyType Policies) + - Issue #12 (Page Permission Plug) + +### After Issue #8-#12 (Policies) + +Can work in parallel: +- Issue #13 (Email Validation) +- Issue #14 (Seeds) + +### Final Phase (Sequential) + +- Issue #15 (UI Authorization Helper) - needs #6, #13, #14 +- Issue #16 (Admin UI) - needs #15 +- Issue #17 (UI Auth in LiveViews) - needs #15, #16 +- Issue #18 (Integration Tests) - needs everything + +--- + +## Sprint 4: UI & Integration (Week 4) + +### Issue #15: UI Authorization Helper Module + +**Size:** M (2-3 days) +**Dependencies:** #6 (Cache), #13 (Email Validation), #14 (Seeds) +**Can work in parallel:** No (needs cache and seeds) +**Assignable to:** Backend Developer + Frontend Developer + +#### Description + +Create helper module for UI-level authorization checks in LiveView templates and modules. + +#### Tasks + +1. Create `lib/mv_web/authorization.ex` +2. Implement `can?/3` for resource-level permissions (atom resource) +3. Implement `can?/3` for record-level permissions (struct) +4. Implement `can_access_page?/2` for page permissions +5. Add private helpers for cache integration +6. Add scope checking for records (own, linked, all) +7. Add comprehensive documentation and examples + +#### Test Strategy (TDD) + +```elixir +# test/mv_web/authorization_test.exs +defmodule MvWeb.AuthorizationTest do + use Mv.DataCase, async: false + + import MvWeb.Authorization + + describe "can?/3 with resource atom" do + test "returns true when user has permission" do + user = create_user_with_permission("Member", "read", "all", true) + + assert can?(user, :read, Mv.Membership.Member) == true + end + + test "returns false when user lacks permission" do + user = create_user_without_permission() + + assert can?(user, :read, Mv.Membership.Member) == false + end + + test "returns false for nil user" do + assert can?(nil, :read, Mv.Membership.Member) == false + end + + test "uses cache when available" do + user = create_user_with_permission("Member", "read", "all", true) + + # First call - cache miss + assert can?(user, :read, Mv.Membership.Member) == true + + # Verify cache was populated + assert {:ok, _} = Mv.Authorization.PermissionCache.get_permission_set(user.id) + + # Second call - cache hit + assert can?(user, :read, Mv.Membership.Member) == true + end + end + + describe "can?/3 with record struct and scope='all'" do + test "returns true for any record when user has scope='all'" do + user = create_user_with_permission("Member", "update", "all", true) + member = create_member() + + assert can?(user, :update, member) == true + end + end + + describe "can?/3 with record struct and scope='own'" do + test "returns true for own user record" do + user = create_user() + + # Users always have own data access + assert can?(user, :read, user) == true + end + + test "returns false for other user record" do + user1 = create_user_with_role("Mitglied") + user2 = create_user_with_role("Mitglied") + + assert can?(user1, :read, user2) == false + end + end + + describe "can?/3 with record struct and scope='linked'" do + test "returns true for linked member" do + user = create_user() + member = create_member_linked_to_user(user) + + assert can?(user, :read, member) == true + end + + test "returns false for unlinked member" do + user = create_user_with_role("Mitglied") + member = create_member() # Not linked to user + + assert can?(user, :read, member) == false + end + + test "returns true for property of linked member" do + user = create_user() + member = create_member_linked_to_user(user) + property = create_property(member) + + assert can?(user, :read, property) == true + end + + test "returns false for property of unlinked member" do + user = create_user_with_role("Mitglied") + member = create_member() + property = create_property(member) + + assert can?(user, :read, property) == false + end + end + + describe "can_access_page?/2" do + test "returns true when user has page permission" do + user = create_user_with_page_permission("/members") + + assert can_access_page?(user, "/members") == true + end + + test "returns false when user lacks page permission" do + user = create_user_with_role("Mitglied") + + assert can_access_page?(user, "/users") == false + end + + test "returns false for nil user" do + assert can_access_page?(nil, "/members") == false + end + + test "caches page permissions" do + user = create_user_with_page_permission("/members") + + # First call + assert can_access_page?(user, "/members") == true + + # Verify cache + assert {:ok, true} = + Mv.Authorization.PermissionCache.get_page_permission(user.id, "/members") + + # Second call uses cache + assert can_access_page?(user, "/members") == true + end + end +end +``` + +#### Definition of Done + +- [ ] `MvWeb.Authorization` module created +- [ ] All `can?/3` variants implemented +- [ ] `can_access_page?/2` implemented +- [ ] Scope checking works correctly (own, linked, all) +- [ ] Cache integration works +- [ ] All helper tests pass +- [ ] Documentation complete with examples + +--- + +### Issue #16: Admin UI for Role Management + +**Size:** L (3-4 days) +**Dependencies:** #3, #8, #9, #14, #15 +**Can work in parallel:** No (needs everything else) +**Assignable to:** Frontend Developer + Backend Developer + +#### Description + +Create LiveView pages for managing roles and assigning them to users. Uses UI Authorization helpers from #15. + +#### Tasks + +1. Create `RoleLive.Index` for listing roles +2. Create `RoleLive.Form` for creating/editing roles +3. Create `UserLive` extension for role assignment +4. Add permission checks using `can?` helper (only admin can access) +5. Show which permission set each role uses +6. Allow changing role's permission set +7. Show users assigned to each role +8. Implement UI authorization for buttons/links + +#### Test Strategy (TDD) + +```elixir +# test/mv_web/role_live/index_test.exs +defmodule MvWeb.RoleLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "RoleLive.Index access control" do + test "admin can access role management page", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/admin/roles") + + assert html =~ "Roles" + end + + test "non-admin cannot access role management page", %{conn: conn} do + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, user) + + {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/roles") + end + end + + describe "RoleLive.Index display" do + test "displays all roles", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/admin/roles") + + assert html =~ "Mitglied" + assert html =~ "Admin" + assert html =~ "Vorstand" + end + + test "system roles cannot be deleted", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + mitglied = get_role_by_name("Mitglied") + + {:ok, view, _html} = live(conn, ~p"/admin/roles") + + # Delete button should not exist for system roles + refute has_element?(view, "#role-#{mitglied.id} .delete-button") + end + end + + describe "RoleLive role creation" do + test "can create new role", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, _html} = live(conn, ~p"/admin/roles") + + view + |> element("a", "New Role") + |> render_click() + + view + |> form("#role-form", role: %{ + name: "Test Role", + description: "Test", + permission_set_id: get_permission_set_id("read_only") + }) + |> render_submit() + + assert_patch(view, ~p"/admin/roles") + assert render(view) =~ "Test Role" + end + end +end + +# test/mv_web/user_live/index_test.exs (extension) +describe "UserLive role assignment" do + test "admin can change user's role", %{conn: conn} do + admin = create_user_with_role("Admin") + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, admin) + + {:ok, view, _html} = live(conn, ~p"/users") + + view + |> element("#user-#{user.id} .role-selector") + |> render_change(%{role_id: get_role_id("Vorstand")}) + + updated_user = Ash.reload!(user) + assert updated_user.role_id == get_role_id("Vorstand") + end + + test "invalidates cache when role changed", %{conn: conn} do + admin = create_user_with_role("Admin") + user = create_user_with_role("Mitglied") + + # Populate cache + Mv.Authorization.PermissionCache.put_permission_set(user.id, %{}) + + conn = log_in_user(conn, admin) + {:ok, view, _html} = live(conn, ~p"/users") + + view + |> element("#user-#{user.id} .role-selector") + |> render_change(%{role_id: get_role_id("Vorstand")}) + + # Cache should be invalidated + assert :miss = Mv.Authorization.PermissionCache.get_permission_set(user.id) + end +end +``` + +#### Definition of Done + +- [ ] Role management UI created +- [ ] Only admin can access (enforced with `can_access_page?`) +- [ ] Can create/edit/delete roles +- [ ] System roles cannot be deleted (UI hidden with `can?`) +- [ ] Can assign roles to users +- [ ] Cache invalidation on changes +- [ ] All UI tests pass +- [ ] Uses `can?` and `can_access_page?` helpers throughout + +--- + +### Issue #17: Apply UI Authorization to Existing LiveViews + +**Size:** L (3-4 days) +**Dependencies:** #15, #16 +**Can work in parallel:** No (needs UI helpers and Admin UI as example) +**Assignable to:** Frontend Developer + +#### Description + +Update all existing LiveView templates and modules to use UI authorization helpers, hiding links and buttons based on permissions. + +#### Tasks + +1. Update `lib/mv_web/components/layouts/navbar.html.heex` with `can_access_page?` +2. Update `MemberLive.Index` - hide "New Member" button, Edit/Delete per row +3. Update `MemberLive.Show` - hide Edit/Delete buttons +4. Update `UserLive.Index` - show only if admin +5. Update `PropertyLive.Index` - check permissions +6. Update `PropertyTypeLive.Index` - show Edit/Delete only for admin +7. Import `MvWeb.Authorization` in all relevant LiveView modules +8. Add permission checks in `mount` functions where appropriate + +#### Test Strategy (TDD) + +```elixir +# test/mv_web/member_live/index_test.exs +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "UI authorization for Mitglied role" do + test "does not show 'New Member' button", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + refute html =~ "New Member" + refute has_element?(view, "a", "New Member") + end + + test "shows only 'Show' button for own member", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + # Show button should exist + assert has_element?(view, "a[href='/members/#{member.id}']", "Show") + + # Edit and Delete buttons should NOT exist + refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") + refute has_element?(view, "button[phx-click='delete']", "Delete") + end + end + + describe "UI authorization for Kassenwart role" do + test "shows 'New Member' button", %{conn: conn} do + user = create_user_with_role("Kassenwart") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "New Member" + assert has_element?(view, "a", "New Member") + end + + test "shows Edit and Delete buttons for all members", %{conn: conn} do + user = create_user_with_role("Kassenwart") + member1 = create_member() + member2 = create_member() + conn = log_in_user(conn, user) + + {:ok, view, _html} = live(conn, ~p"/members") + + # Both members should have Edit and Delete buttons + assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit") + assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit") + + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"])) + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"])) + end + end + + describe "UI authorization for Admin role" do + test "shows all action buttons", %{conn: conn} do + admin = create_user_with_role("Admin") + member = create_member() + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "New Member" + assert has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member.id}"])) + end + end +end + +# test/mv_web/components/layouts/navbar_test.exs +defmodule MvWeb.Layouts.NavbarTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "navigation links for Mitglied role" do + test "does not show admin links", %{conn: conn} do + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/") + + refute html =~ "Users" + refute html =~ "Custom Fields" + refute html =~ "Roles" + end + end + + describe "navigation links for Admin role" do + test "shows all navigation links", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/") + + assert html =~ "Members" + assert html =~ "Users" + assert html =~ "Custom Fields" + assert html =~ "Roles" + end + end +end +``` + +#### Definition of Done + +- [ ] Navbar updated with `can_access_page?` checks +- [ ] All MemberLive pages updated +- [ ] All UserLive pages updated +- [ ] All PropertyLive pages updated +- [ ] All PropertyTypeLive pages updated +- [ ] All LiveView modules import `MvWeb.Authorization` +- [ ] All UI authorization tests pass +- [ ] No unauthorized buttons/links visible + +--- + +### Issue #18: Integration Tests - Complete Scenarios + +**Size:** L (3 days) +**Dependencies:** All previous issues +**Can work in parallel:** No (needs everything) +**Assignable to:** Backend Developer + QA + +#### Description + +Write comprehensive integration tests for complete user journeys across all roles. + +#### Test Strategy + +```elixir +# test/mv/authorization/integration_test.exs +defmodule Mv.Authorization.IntegrationTest do + use Mv.DataCase, async: false + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import MvWeb.Authorization + + describe "Complete Mitglied user journey" do + test "can only access own data" do + # Setup + member = create_member() + user = create_user_linked_to_member(member) + assign_role(user, "Mitglied") + + # Can read own member + {:ok, fetched} = Ash.get(Mv.Membership.Member, member.id, actor: user) + assert fetched.id == member.id + + # Can update own member + {:ok, updated} = Ash.update(member, %{first_name: "New"}, actor: user) + assert updated.first_name == "New" + + # Cannot read other members + other_member = create_member() + {:ok, members} = Ash.read(Mv.Membership.Member, actor: user) + assert length(members) == 1 + + # Can always update own credentials + {:ok, updated_user} = Ash.update(user, %{email: "new@example.com"}, actor: user) + assert updated_user.email == "new@example.com" + + # UI: No "New Member" button + assert can?(user, :create, Mv.Membership.Member) == false + + # UI: No "Users" link + assert can_access_page?(user, "/users") == false + end + end + + describe "Complete Kassenwart user journey" do + test "can manage all members but not users" do + user = create_user_with_role("Kassenwart") + member1 = create_member() + member2 = create_member() + + # Can read all members + {:ok, members} = Ash.read(Mv.Membership.Member, actor: user) + assert length(members) == 2 + + # Can update members + {:ok, updated} = Ash.update(member1, %{first_name: "Updated"}, actor: user) + assert updated.first_name == "Updated" + + # Can create members + {:ok, new_member} = Mv.Membership.create_member( + %{first_name: "New", last_name: "Member", email: "new@example.com"}, + actor: user + ) + assert new_member.first_name == "New" + + # Cannot access users + assert {:error, %Ash.Error.Forbidden{}} = + Ash.read(Mv.Accounts.User, actor: user) + + # UI: Has "New Member" button + assert can?(user, :create, Mv.Membership.Member) == true + + # UI: Can access edit pages + assert can_access_page?(user, "/members/:id/edit") == true + + # UI: Cannot access users page + assert can_access_page?(user, "/users") == false + end + end + + describe "Complete Admin user journey" do + test "has full access to everything" do + admin = create_user_with_role("Admin") + user = create_user_with_role("Mitglied") + member = create_member() + + # Can manage all resources + {:ok, members} = Ash.read(Mv.Membership.Member, actor: admin) + {:ok, users} = Ash.read(Mv.Accounts.User, actor: admin) + + # Can update other users' credentials + {:ok, updated_user} = Ash.update( + user, + %{email: "admin-changed@example.com"}, + actor: admin + ) + assert updated_user.email == "admin-changed@example.com" + + # Can manage roles + {:ok, new_role} = Mv.Authorization.create_role( + %{name: "New Role", permission_set_id: get_permission_set_id("read_only")}, + actor: admin + ) + assert new_role.name == "New Role" + + # UI: Can access all pages + assert can_access_page?(admin, "/admin") == true + assert can_access_page?(admin, "/users") == true + assert can_access_page?(admin, "/admin/roles") == true + end + end + + describe "UI and Ash policy consistency" do + test "UI never shows action that Ash would forbid" do + # For each role, verify UI and Ash agree + roles = ["Mitglied", "Vorstand", "Kassenwart", "Buchhaltung", "Admin"] + + for role_name <- roles do + user = create_user_with_role(role_name) + + # Test Member actions + if can?(user, :create, Mv.Membership.Member) do + # If UI says yes, Ash should allow + assert {:ok, _} = Mv.Membership.create_member( + %{first_name: "Test", last_name: "User", email: "test@example.com"}, + actor: user + ) + else + # If UI says no, Ash should forbid + assert {:error, %Ash.Error.Forbidden{}} = + Mv.Membership.create_member( + %{first_name: "Test", last_name: "User", email: "test@example.com"}, + actor: user + ) + end + + # Test User access + if can_access_page?(user, "/users") do + # If UI shows link, Ash should allow read + assert {:ok, _} = Ash.read(Mv.Accounts.User, actor: user) + else + # If UI hides link, Ash should forbid or return empty + case Ash.read(Mv.Accounts.User, actor: user) do + {:error, %Ash.Error.Forbidden{}} -> assert true + {:ok, []} -> assert true # Filtered to nothing + {:ok, [%{id: id}]} -> assert id == user.id # Only own user + end + end + end + end + end + + describe "Cache invalidation flows" do + test "role change invalidates cache and updates UI permissions" do + user = create_user_with_role("Mitglied") + + # Mitglied cannot create members + assert can?(user, :create, Mv.Membership.Member) == false + + # Change to Kassenwart + assign_role(user, "Kassenwart") + + # Cache should be invalidated + assert :miss = Mv.Authorization.PermissionCache.get_permission_set(user.id) + + # Reload user + user = Ash.reload!(user) + + # Now can create members + assert can?(user, :create, Mv.Membership.Member) == true + end + end +end +``` + +#### Definition of Done + +- [ ] All user journeys tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) +- [ ] All special cases covered (email validation, own credentials, linked members) +- [ ] UI and Ash policy consistency verified +- [ ] Cache behavior verified across all scenarios +- [ ] Cross-resource authorization works +- [ ] All integration tests pass +- [ ] Test coverage meets goals (>80%) + +--- + +## Summary + +### Overview + +**Total Issues:** 18 +**Estimated Duration:** 4-5 weeks +**Team Size:** 2-3 Backend Developers + 1 Frontend Developer + +### Parallelization Opportunities + +| Sprint | Max Parallel Issues | Sequential Issues | +|--------|---------------------|-------------------| +| Sprint 1 | 3 | 2 | +| Sprint 2 | 5 | 2 | +| Sprint 3 | 2 | 0 | +| Sprint 4 | 1 | 3 | + +### Test Coverage + +**Estimated Test Count:** 350+ tests + +| Test Type | Count | Coverage | +|-----------|-------|----------| +| Unit Tests | ~160 | Resource CRUD, Policy checks, Cache operations, UI helpers | +| Integration Tests | ~120 | Cross-resource authorization, Special cases, UI/Ash consistency | +| LiveView Tests | ~60 | Page permissions, UI interactions, Authorization display | +| E2E Tests | ~10 | Complete user journeys | + +### Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| Cache invalidation bugs | Medium | High | Comprehensive tests, manual testing | +| Policy order issues | Medium | High | Clear documentation, integration tests | +| Performance degradation | Low | Medium | Cache layer, performance tests | +| Scope filter errors | Medium | High | TDD approach, extensive testing | +| Breaking existing auth | Low | High | Feature flag, gradual rollout | + +--- + +## Data Migration + +### Existing Users + +All existing users will be assigned the "Mitglied" (Member) role by default: + +```sql +-- Migration: Set default role for existing users +-- This happens in Issue #14 seeds +UPDATE users +SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') +WHERE role_id IS NULL; +``` + +### Backward Compatibility + +**Phase 1 (This Implementation):** +- No existing authorization system to maintain +- Clean slate implementation +- All tests ensure new system works correctly + +**Phase 2 (Field-Level - Future):** +- Existing `permission_set_resources` with `field_name = NULL` continue to work +- No migration needed, just add new field-specific permissions +- Backward compatible by design + +### Rollback Plan + +If critical issues are discovered after deployment: + +1. **Database Rollback:** +```bash +# Rollback all authorization migrations +mix ecto.rollback --step 1 # Or specific migration +``` + +2. **Code Rollback:** +- Remove authorization policies from resources +- Comment out PermissionCache from supervision tree +- Remove page permission plug from router + +3. **Verification:** +- Test that existing functionality still works +- Verify no permission checks blocking access +- Check logs for errors + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-11-10 | Development Team | Initial implementation plan | + +--- + +**Related Documents:** +- [Architecture Design](./roles-and-permissions-architecture.md) +- [Code Guidelines](../CODE_GUIDELINES.md) +- [Database Schema](./database-schema-readme.md) + +--- + +**End of Document** +