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.
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
- Overview
- Test-Driven Development Approach
- Issue Dependency Graph
- Sprint 1: Foundation
- Sprint 2: Policy System
- Sprint 3: Special Cases & Seeds
- Sprint 4: UI & Integration
- Parallel Work Opportunities
- Summary
- 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 - Complete system architecture and design decisions
Test-Driven Development Approach
This feature will be implemented using Test-Driven Development (TDD):
TDD Workflow
-
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
-
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
-
Refactor Phase - Clean Up:
- Clean up code while keeping tests green
- Improve structure, naming, and organization
- Ensure code follows guidelines
-
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
- Create migration for
permission_setstable - Create migration for
permission_set_resourcestable - Create migration for
permission_set_pagestable - Create migration for
rolestable - Add
role_idcolumn touserstable
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
- Run tests (they should fail)
- Create migration file:
priv/repo/migrations/TIMESTAMP_add_authorization_tables.exs - Implement migrations following the schema in architecture document
- Run migrations
- 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
- Create
lib/mv/authorization/permission_set.ex - Create
lib/mv/authorization.ex(Domain module) - Define attributes (name, description, is_system)
- Define actions (read, create, update, destroy)
- Add validation to prevent deletion of system permission sets
- Add code_interface for easy access
- 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
- Create
lib/mv/authorization/role.ex - Define attributes (name, description, is_system_role)
- Define
belongs_torelationship to PermissionSet - Define actions (read, create, update, destroy)
- Add validation to prevent deletion of system roles
- Add cache invalidation after update (prepare for Issue #6)
- Add code_interface
- 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
- Create
lib/mv/authorization/permission_set_resource.ex - Define attributes (resource_name, action, scope, field_name, granted)
- Define
belongs_torelationship to PermissionSet - Define actions (read, create, update, destroy)
- Add unique constraint validation
- Add cache invalidation on changes
- Add code_interface
- 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
- Create
lib/mv/authorization/permission_set_page.ex - Define attributes (page_path)
- Define
belongs_torelationship to PermissionSet - Define actions (read, create, update, destroy)
- Add unique constraint validation
- Add code_interface
- 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
- Create
lib/mv/authorization/permission_cache.ex - Implement GenServer for cache management
- Create ETS table with appropriate configuration
- Add functions:
get_permission_set/1,put_permission_set/2 - Add functions:
get_page_permission/2,put_page_permission/3 - Add invalidation functions:
invalidate_user/1,invalidate_all/0 - Add to application supervision tree (
lib/mv/application.ex) - 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
- Create
lib/mv/authorization/checks/has_resource_permission.ex - Implement Ash.Policy.Check behavior
- Implement
match?/3function - Implement scope evaluation (own, linked, all)
- Integrate with permission cache
- Handle all resource types (Member, User, Property, PropertyType)
- 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
- Create
lib/mv_web/authorization.ex - Implement
can?/3for resource-level permissions (atom resource) - Implement
can?/3for record-level permissions (struct) - Implement
can_access_page?/2for page permissions - Add private helpers for cache integration
- Add scope checking for records (own, linked, all)
- 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.Authorizationmodule created- All
can?/3variants implemented can_access_page?/2implemented- 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
- Create
RoleLive.Indexfor listing roles - Create
RoleLive.Formfor creating/editing roles - Create
UserLiveextension for role assignment - Add permission checks using
can?helper (only admin can access) - Show which permission set each role uses
- Allow changing role's permission set
- Show users assigned to each role
- 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?andcan_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
- Update
lib/mv_web/components/layouts/navbar.html.heexwithcan_access_page? - Update
MemberLive.Index- hide "New Member" button, Edit/Delete per row - Update
MemberLive.Show- hide Edit/Delete buttons - Update
UserLive.Index- show only if admin - Update
PropertyLive.Index- check permissions - Update
PropertyTypeLive.Index- show Edit/Delete only for admin - Import
MvWeb.Authorizationin all relevant LiveView modules - Add permission checks in
mountfunctions 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_resourceswithfield_name = NULLcontinue to work - No migration needed, just add new field-specific permissions
- Backward compatible by design
Rollback Plan
If critical issues are discovered after deployment:
- Database Rollback:
# Rollback all authorization migrations
mix ecto.rollback --step 1 # Or specific migration
- Code Rollback:
- Remove authorization policies from resources
- Comment out PermissionCache from supervision tree
- Remove page permission plug from router
- 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