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.
2368 lines
75 KiB
Markdown
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**
|
|
|