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

2368 lines
75 KiB
Markdown

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