mitgliederverwaltung/docs/roles-and-permissions-implementation-plan.md
Moritz 1084f67f1f
docs: Add roles and permissions architecture and implementation plan
Complete RBAC system design with permission sets, Ash policies, and UI authorization.
Implementation broken down into 18 issues across 4 sprints with TDD approach.
Includes database schema, caching strategy, and comprehensive test coverage.
2025-11-13 13:43:58 +01:00

75 KiB

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
  2. Test-Driven Development Approach
  3. Issue Dependency Graph
  4. Sprint 1: Foundation
  5. Sprint 2: Policy System
  6. Sprint 3: Special Cases & Seeds
  7. Sprint 4: UI & Integration
  8. Parallel Work Opportunities
  9. Summary
  10. 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:


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:

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

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

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

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

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

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

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

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

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

# 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

# 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:

-- 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:
# Rollback all authorization migrations
mix ecto.rollback --step 1  # Or specific migration
  1. Code Rollback:
  • Remove authorization policies from resources
  • Comment out PermissionCache from supervision tree
  • Remove page permission plug from router
  1. 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:


End of Document