1653 lines
57 KiB
Markdown
1653 lines
57 KiB
Markdown
# Roles and Permissions - Implementation Plan (MVP)
|
|
|
|
**Version:** 2.0 (Clean Rewrite)
|
|
**Date:** 2025-01-13
|
|
**Status:** Ready for Implementation
|
|
**Related Documents:**
|
|
- [Overview](./roles-and-permissions-overview.md) - High-level concepts
|
|
- [Architecture](./roles-and-permissions-architecture.md) - Technical specification
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [Executive Summary](#executive-summary)
|
|
- [MVP Scope](#mvp-scope)
|
|
- [Implementation Strategy](#implementation-strategy)
|
|
- [Issue Breakdown](#issue-breakdown)
|
|
- [Sprint 1: Foundation](#sprint-1-foundation-week-1)
|
|
- [Sprint 2: Policies](#sprint-2-policies-week-2)
|
|
- [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3)
|
|
- [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4)
|
|
- [Dependencies & Parallelization](#dependencies--parallelization)
|
|
- [Testing Strategy](#testing-strategy)
|
|
- [Migration & Rollback](#migration--rollback)
|
|
- [Risk Management](#risk-management)
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
### Overview
|
|
|
|
This document defines the implementation plan for the **MVP (Phase 1)** of the Roles and Permissions system using **hardcoded Permission Sets** in an Elixir module.
|
|
|
|
**Key Characteristics:**
|
|
- **15 issues total** (Issues #1-3, #6-17)
|
|
- **2-3 weeks duration**
|
|
- **180+ tests**
|
|
- **Test-Driven Development (TDD)** throughout
|
|
- **No database tables for permissions** - only `roles` table
|
|
- **Zero performance concerns** - all permission checks are in-memory function calls
|
|
|
|
### What's NOT in MVP
|
|
|
|
**Deferred to Phase 3 (Future):**
|
|
- Issue #4: `PermissionSetResource` database table
|
|
- Issue #5: `PermissionSetPage` database table
|
|
- Issue #18: ETS Permission Cache
|
|
- Database-backed dynamic permissions
|
|
|
|
### The Four Permission Sets
|
|
|
|
Hardcoded in `Mv.Authorization.PermissionSets` module:
|
|
|
|
1. **own_data** - User can only access their own data (default for "Mitglied")
|
|
2. **read_only** - Read access to all members/properties (for "Vorstand", "Buchhaltung")
|
|
3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart")
|
|
4. **admin** - Unrestricted access including user/role management (for "Admin")
|
|
|
|
### The Five Roles
|
|
|
|
Stored in database `roles` table, each referencing a `permission_set_name`:
|
|
|
|
1. **Mitglied** → "own_data" (is_system_role=true, default)
|
|
2. **Vorstand** → "read_only"
|
|
3. **Kassenwart** → "normal_user"
|
|
4. **Buchhaltung** → "read_only"
|
|
5. **Admin** → "admin"
|
|
|
|
---
|
|
|
|
## MVP Scope
|
|
|
|
### What We're Building
|
|
|
|
**Core Authorization System:**
|
|
- ✅ Hardcoded PermissionSets module with 4 permission sets
|
|
- ✅ Role database table and CRUD interface
|
|
- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets
|
|
- ✅ Policies on all resources (Member, User, Property, PropertyType, Role)
|
|
- ✅ Page-level permissions via Phoenix Plug
|
|
- ✅ UI authorization helpers for conditional rendering
|
|
- ✅ Special case: Member email validation for linked users
|
|
- ✅ Seed data for 5 roles
|
|
|
|
**Benefits of Hardcoded Approach:**
|
|
- **Speed:** 2-3 weeks vs. 4-5 weeks for DB-backed
|
|
- **Performance:** < 1 microsecond per check (pure function call)
|
|
- **Simplicity:** No cache, no DB queries, easy to reason about
|
|
- **Version Control:** All permission changes tracked in Git
|
|
- **Testing:** Deterministic, no DB setup needed
|
|
|
|
**Clear Migration Path to Phase 3:**
|
|
- Architecture document defines exact DB schema for future
|
|
- HasPermission check can be swapped for DB-querying version
|
|
- Role->PermissionSet link remains unchanged
|
|
|
|
---
|
|
|
|
## Implementation Strategy
|
|
|
|
### Test-Driven Development
|
|
|
|
**Every issue follows TDD:**
|
|
1. Write failing tests first
|
|
2. Implement minimum code to pass tests
|
|
3. Refactor if needed
|
|
4. All tests must pass before moving on
|
|
|
|
**Test Types:**
|
|
- **Unit Tests:** Individual modules (PermissionSets, Policy checks, Helpers)
|
|
- **Integration Tests:** Cross-resource authorization, special cases
|
|
- **LiveView Tests:** UI rendering, page permissions
|
|
- **E2E Tests:** Complete user journeys (one per role)
|
|
|
|
### Incremental Rollout
|
|
|
|
**Feature Flag Approach:**
|
|
- Implement behind environment variable `ENABLE_RBAC`
|
|
- Default: `false` (existing auth remains active)
|
|
- Test thoroughly in staging
|
|
- Flip flag in production after validation
|
|
- Allows instant rollback if needed
|
|
|
|
### Definition of Done (All Issues)
|
|
|
|
- [ ] All acceptance criteria met
|
|
- [ ] All tests written and passing
|
|
- [ ] Code reviewed and approved
|
|
- [ ] Documentation updated
|
|
- [ ] No linter errors
|
|
- [ ] Manual testing completed
|
|
- [ ] Feature flag tested (on/off states)
|
|
|
|
---
|
|
|
|
## Issue Breakdown
|
|
|
|
### Sprint 1: Foundation (Week 1)
|
|
|
|
#### Issue #1: Create Authorization Domain and Role Resource
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** None
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Create the authorization domain in Ash with the `Role` resource. This establishes the foundation for all authorization logic.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `lib/mv/authorization/` directory
|
|
2. Create `lib/mv/authorization/role.ex` Ash resource with:
|
|
- `id` (UUIDv7, primary key)
|
|
- `name` (String, unique, required) - e.g., "Vorstand", "Admin"
|
|
- `description` (String, optional)
|
|
- `permission_set_name` (String, required) - must be one of: "own_data", "read_only", "normal_user", "admin"
|
|
- `is_system_role` (Boolean, default false) - prevents deletion
|
|
- timestamps
|
|
3. Add validation: `permission_set_name` must exist in `PermissionSets.all_permission_sets/0`
|
|
4. Add `role_id` (UUID, nullable, foreign key) to `users` table
|
|
5. Add `belongs_to :role` relationship in User resource
|
|
6. Run `mix ash.codegen` to generate migrations
|
|
7. Review and apply migrations
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Role resource created with all fields
|
|
- [ ] Migration applied successfully
|
|
- [ ] User.role relationship works
|
|
- [ ] Validation prevents invalid `permission_set_name`
|
|
- [ ] `is_system_role` flag present
|
|
|
|
**Test Strategy:**
|
|
|
|
**Smoke Tests Only** (detailed behavior tests in later issues):
|
|
|
|
- Role resource can be loaded via `Code.ensure_loaded?(Mv.Authorization.Role)`
|
|
- Migration created valid table (manually verify with `psql`)
|
|
- User resource can be loaded and has `:role` in `relationships()`
|
|
|
|
**No extensive behavior tests** - those come in Issue #3 (Role CRUD).
|
|
|
|
**Test File:** `test/mv/authorization/role_test.exs` (minimal smoke tests)
|
|
|
|
---
|
|
|
|
#### Issue #2: PermissionSets Elixir Module (Hardcoded Permissions)
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** None
|
|
**Can work in parallel:** Yes (parallel with #1)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Create the core `PermissionSets` module that defines all four permission sets with their resource and page permissions. This is the heart of the MVP's authorization logic.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `lib/mv/authorization/permission_sets.ex`
|
|
2. Define module with `@moduledoc` explaining the 4 permission sets
|
|
3. Define types:
|
|
```elixir
|
|
@type scope :: :own | :linked | :all
|
|
@type action :: :read | :create | :update | :destroy
|
|
@type resource_permission :: %{
|
|
resource: String.t(),
|
|
action: action(),
|
|
scope: scope(),
|
|
granted: boolean()
|
|
}
|
|
@type permission_set :: %{
|
|
resources: [resource_permission()],
|
|
pages: [String.t()]
|
|
}
|
|
```
|
|
4. Implement `get_permissions/1` for each of the 4 permission sets
|
|
5. Implement `all_permission_sets/0` returning `[:own_data, :read_only, :normal_user, :admin]`
|
|
6. Implement `valid_permission_set?/1` checking if name is in the list
|
|
7. Implement `permission_set_name_to_atom/1` with error handling
|
|
8. Add comprehensive `@doc` examples for each function
|
|
|
|
**Permission Set Details:**
|
|
|
|
**1. own_data (Mitglied):**
|
|
- Resources:
|
|
- User: read/update :own
|
|
- Member: read/update :linked
|
|
- Property: read/update :linked
|
|
- PropertyType: read :all
|
|
- Pages: `["/", "/profile", "/members/:id"]`
|
|
|
|
**2. read_only (Vorstand, Buchhaltung):**
|
|
- Resources:
|
|
- User: read :own, update :own
|
|
- Member: read :all
|
|
- Property: read :all
|
|
- PropertyType: read :all
|
|
- Pages: `["/", "/members", "/members/:id", "/properties"]`
|
|
|
|
**3. normal_user (Kassenwart):**
|
|
- Resources:
|
|
- User: read/update :own
|
|
- Member: read/create/update :all (no destroy for safety)
|
|
- Property: read/create/update/destroy :all
|
|
- PropertyType: read :all
|
|
- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/properties", "/properties/new", "/properties/:id/edit"]`
|
|
|
|
**4. admin:**
|
|
- Resources:
|
|
- User: read/update/destroy :all
|
|
- Member: read/create/update/destroy :all
|
|
- Property: read/create/update/destroy :all
|
|
- PropertyType: read/create/update/destroy :all
|
|
- Role: read/create/update/destroy :all
|
|
- Pages: `["*"]` (wildcard = all pages)
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Module created with all 4 permission sets
|
|
- [ ] `get_permissions/1` returns correct structure for each set
|
|
- [ ] `valid_permission_set?/1` works for atoms and strings
|
|
- [ ] `permission_set_name_to_atom/1` handles errors gracefully
|
|
- [ ] All functions have `@doc` and `@spec`
|
|
- [ ] Code is readable and well-commented
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Structure Tests:**
|
|
- `get_permissions(:own_data)` returns map with `:resources` and `:pages` keys
|
|
- Each permission set returns list of resource permissions
|
|
- Each resource permission has required keys: `:resource`, `:action`, `:scope`, `:granted`
|
|
- Pages lists are non-empty (except potentially for restricted roles)
|
|
|
|
**Permission Content Tests:**
|
|
- `:own_data` allows User read/update with scope :own
|
|
- `:own_data` allows Member/Property read/update with scope :linked
|
|
- `:read_only` allows Member/Property read with scope :all
|
|
- `:read_only` does NOT allow Member/Property create/update/destroy
|
|
- `:normal_user` allows Member/Property full CRUD with scope :all
|
|
- `:admin` allows everything with scope :all
|
|
- `:admin` has wildcard page permission "*"
|
|
|
|
**Validation Tests:**
|
|
- `valid_permission_set?("own_data")` returns true
|
|
- `valid_permission_set?(:admin)` returns true
|
|
- `valid_permission_set?("invalid")` returns false
|
|
- `permission_set_name_to_atom("own_data")` returns `{:ok, :own_data}`
|
|
- `permission_set_name_to_atom("invalid")` returns `{:error, :invalid_permission_set}`
|
|
|
|
**Edge Cases:**
|
|
- All 4 sets defined in `all_permission_sets/0`
|
|
- Function doesn't crash on nil input (returns false/error tuple)
|
|
|
|
**Test File:** `test/mv/authorization/permission_sets_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #3: Role CRUD LiveViews
|
|
|
|
**Size:** M (3 days)
|
|
**Dependencies:** #1 (Role resource)
|
|
**Assignable to:** Backend Developer + Frontend Developer
|
|
|
|
**Description:**
|
|
|
|
Create LiveView interface for administrators to manage roles. Only admins should be able to access this.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `lib/mv_web/live/role_live/` directory
|
|
2. Implement `index.ex` - List all roles
|
|
3. Implement `show.ex` - View role details
|
|
4. Implement `form.ex` - Create/Edit role form component
|
|
5. Add routes in `router.ex` under `/admin` scope
|
|
6. Create table component showing: name, description, permission_set_name, is_system_role
|
|
7. Add form validation for `permission_set_name` (dropdown with 4 options)
|
|
8. Prevent deletion of system roles (UI + backend)
|
|
9. Add flash messages for success/error
|
|
10. Style with existing DaisyUI theme
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Index page lists all roles
|
|
- [ ] Show page displays role details
|
|
- [ ] Form allows creating new roles
|
|
- [ ] Form allows editing non-system roles
|
|
- [ ] `permission_set_name` is dropdown (not free text)
|
|
- [ ] Cannot delete system roles (grayed out button + backend check)
|
|
- [ ] All CRUD operations work
|
|
- [ ] Routes are under `/admin/roles`
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**LiveView Mount Tests:**
|
|
- Index page mounts successfully
|
|
- Index page loads all roles from database
|
|
- Show page mounts with valid role ID
|
|
- Show page returns 404 for invalid role ID
|
|
|
|
**CRUD Operation Tests:**
|
|
- Create new role with valid data succeeds
|
|
- Create new role with invalid `permission_set_name` shows error
|
|
- Update role name succeeds
|
|
- Update system role's `permission_set_name` succeeds
|
|
- Delete non-system role succeeds
|
|
- Delete system role fails with error message
|
|
|
|
**UI Rendering Tests:**
|
|
- Index page shows table with role names
|
|
- System roles have badge/indicator
|
|
- Delete button disabled for system roles
|
|
- Form dropdown shows all 4 permission sets
|
|
- Flash messages appear after actions
|
|
|
|
**Test File:** `test/mv_web/live/role_live_test.exs`
|
|
|
|
---
|
|
|
|
### Sprint 2: Policies (Week 2)
|
|
|
|
#### Issue #6: Custom Policy Check - HasPermission
|
|
|
|
**Size:** L (3-4 days)
|
|
**Dependencies:** #2 (PermissionSets), #3 (Role resource exists)
|
|
**Assignable to:** Senior Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Create the core custom Ash Policy Check that reads permissions from the `PermissionSets` module and applies them to Ash queries. This is the bridge between hardcoded permissions and Ash's authorization system.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `lib/mv/authorization/checks/has_permission.ex`
|
|
2. Implement `use Ash.Policy.Check`
|
|
3. Implement `describe/1` - returns human-readable description
|
|
4. Implement `match?/3` - the core authorization logic:
|
|
- Extract `actor.role.permission_set_name`
|
|
- Convert to atom via `PermissionSets.permission_set_name_to_atom/1`
|
|
- Call `PermissionSets.get_permissions/1`
|
|
- Find matching permission for current resource + action
|
|
- Apply scope filter
|
|
5. Implement `apply_scope/3` helper:
|
|
- `:all` → `:authorized` (no filter)
|
|
- `:own` → `{:filter, expr(id == ^actor.id)}`
|
|
- `:linked` → resource-specific logic:
|
|
- Member: `{:filter, expr(user_id == ^actor.id)}`
|
|
- Property: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!)
|
|
6. Handle errors gracefully:
|
|
- No actor → `{:error, :no_actor}`
|
|
- No role → `{:error, :no_role}`
|
|
- Invalid permission_set_name → `{:error, :invalid_permission_set}`
|
|
- No matching permission → `{:error, :no_permission}`
|
|
7. Add logging for authorization failures (debug level)
|
|
8. Add comprehensive `@doc` with examples
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Check module implements `Ash.Policy.Check` behavior
|
|
- [ ] `match?/3` correctly evaluates permissions from PermissionSets
|
|
- [ ] Scope filters work correctly (:all, :own, :linked)
|
|
- [ ] `:linked` scope handles Member and Property differently
|
|
- [ ] Errors are handled gracefully (no crashes)
|
|
- [ ] Authorization failures are logged
|
|
- [ ] Module is well-documented
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Permission Lookup Tests:**
|
|
- Actor with :admin permission_set has permission for all resources/actions
|
|
- Actor with :read_only permission_set has read permission for Member
|
|
- Actor with :read_only permission_set does NOT have create permission for Member
|
|
- Actor with :own_data permission_set has update permission for User with scope :own
|
|
|
|
**Scope Application Tests - :all:**
|
|
- Actor with scope :all can access any record
|
|
- Query returns all records in database
|
|
|
|
**Scope Application Tests - :own:**
|
|
- Actor with scope :own can access record where record.id == actor.id
|
|
- Actor with scope :own cannot access record where record.id != actor.id
|
|
- Query filters to only actor's own record
|
|
|
|
**Scope Application Tests - :linked:**
|
|
- Actor with scope :linked can access Member where member.user_id == actor.id
|
|
- Actor with scope :linked can access Property where property.member.user_id == actor.id (relationship traversal!)
|
|
- Actor with scope :linked cannot access unlinked member
|
|
- Query correctly filters based on user_id relationship
|
|
|
|
**Error Handling Tests:**
|
|
- `match?` with nil actor returns `{:error, :no_actor}`
|
|
- `match?` with actor missing role returns `{:error, :no_role}`
|
|
- `match?` with invalid permission_set_name returns `{:error, :invalid_permission_set}`
|
|
- `match?` with no matching permission returns `{:error, :no_permission}`
|
|
- No crashes on edge cases
|
|
|
|
**Logging Tests:**
|
|
- Authorization failure logs at debug level
|
|
- Log includes actor ID, resource, action, reason
|
|
|
|
**Test Files:**
|
|
- `test/mv/authorization/checks/has_permission_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #7: Member Resource Policies
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** #6 (HasPermission check)
|
|
**Can work in parallel:** Yes (parallel with #8, #9, #10)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Add authorization policies to the Member resource using the new `HasPermission` check.
|
|
|
|
**Tasks:**
|
|
|
|
1. Open `lib/mv/membership/member.ex`
|
|
2. Add `policies` block at top of resource (before actions)
|
|
3. Configure policy to `Mv.Authorization.Checks.HasPermission`
|
|
4. Add policy for each action:
|
|
- `:read` → check HasPermission for :read
|
|
- `:create` → check HasPermission for :create
|
|
- `:update` → check HasPermission for :update
|
|
- `:destroy` → check HasPermission for :destroy
|
|
5. Add special policy: Allow user to read/update their linked member (before general policy)
|
|
```elixir
|
|
policy action_type(:read) do
|
|
authorize_if expr(user_id == ^actor(:id))
|
|
end
|
|
```
|
|
6. Ensure policies load actor with `:role` relationship preloaded
|
|
7. Test policies with different actors
|
|
|
|
**Policy Order (Critical!):**
|
|
1. Allow user to access their own linked member (most specific)
|
|
2. Check HasPermission (general authorization)
|
|
3. Default: Forbid
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Policies block added to Member resource
|
|
- [ ] All CRUD actions protected by HasPermission
|
|
- [ ] Special case: User can always access linked member
|
|
- [ ] Policy order is correct (specific before general)
|
|
- [ ] Actor preloads :role relationship
|
|
- [ ] All policies tested
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Policy Tests for :own_data (Mitglied):**
|
|
- User can read their linked member (user_id matches)
|
|
- User can update their linked member
|
|
- User cannot read unlinked member (returns empty list or forbidden)
|
|
- User cannot create member
|
|
- Verify scope :linked works
|
|
|
|
**Policy Tests for :read_only (Vorstand):**
|
|
- User can read all members (returns all records)
|
|
- User cannot create member (returns Forbidden)
|
|
- User cannot update any member (returns Forbidden)
|
|
- User cannot destroy any member (returns Forbidden)
|
|
|
|
**Policy Tests for :normal_user (Kassenwart):**
|
|
- User can read all members
|
|
- User can create new member
|
|
- User can update any member
|
|
- User cannot destroy member (not in permission set)
|
|
|
|
**Policy Tests for :admin:**
|
|
- User can perform all CRUD operations on any member
|
|
- No restrictions
|
|
|
|
**Test File:** `test/mv/membership/member_policies_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #8: User Resource Policies
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** #6 (HasPermission check)
|
|
**Can work in parallel:** Yes (parallel with #7, #9, #10)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Add authorization policies to the User resource. Special case: Users can always read/update their own credentials.
|
|
|
|
**Tasks:**
|
|
|
|
1. Open `lib/mv/accounts/user.ex`
|
|
2. Add `policies` block
|
|
3. Add special policy: Allow user to always access their own account (before general policy)
|
|
```elixir
|
|
policy action_type([:read, :update]) do
|
|
authorize_if expr(id == ^actor(:id))
|
|
end
|
|
```
|
|
4. Add general policy: Check HasPermission for all actions
|
|
5. Ensure :destroy is admin-only (via HasPermission)
|
|
6. Preload :role relationship for actor
|
|
|
|
**Policy Order:**
|
|
1. Allow user to read/update own account (id == actor.id)
|
|
2. Check HasPermission (for admin operations)
|
|
3. Default: Forbid
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] User can always read/update own credentials
|
|
- [ ] Only admin can read/update other users
|
|
- [ ] Only admin can destroy users
|
|
- [ ] Policy order is correct
|
|
- [ ] Actor preloads :role relationship
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Own Data Tests (All Roles):**
|
|
- User with :own_data can read own user record
|
|
- User with :own_data can update own email/password
|
|
- User with :own_data cannot read other users
|
|
- User with :read_only can read own data
|
|
- User with :normal_user can read own data
|
|
- Verify special policy takes precedence
|
|
|
|
**Admin Tests:**
|
|
- Admin can read all users
|
|
- Admin can update any user's credentials
|
|
- Admin can destroy users
|
|
- Admin has unrestricted access
|
|
|
|
**Forbidden Tests:**
|
|
- Non-admin cannot read other users
|
|
- Non-admin cannot update other users
|
|
- Non-admin cannot destroy users
|
|
|
|
**Test File:** `test/mv/accounts/user_policies_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #9: Property Resource Policies
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** #6 (HasPermission check)
|
|
**Can work in parallel:** Yes (parallel with #7, #8, #10)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Add authorization policies to the Property resource. Properties are linked to members, which are linked to users.
|
|
|
|
**Tasks:**
|
|
|
|
1. Open `lib/mv/membership/property.ex`
|
|
2. Add `policies` block
|
|
3. Add special policy: Allow user to read/update properties of their linked member
|
|
```elixir
|
|
policy action_type([:read, :update]) do
|
|
authorize_if expr(member.user_id == ^actor(:id))
|
|
end
|
|
```
|
|
4. Add general policy: Check HasPermission
|
|
5. Ensure Property preloads :member relationship for scope checks
|
|
6. Preload :role relationship for actor
|
|
|
|
**Policy Order:**
|
|
1. Allow user to read/update properties of linked member
|
|
2. Check HasPermission
|
|
3. Default: Forbid
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] User can access properties of their linked member
|
|
- [ ] Policy traverses Member -> User relationship correctly
|
|
- [ ] HasPermission check works for other scopes
|
|
- [ ] Actor preloads :role relationship
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Linked Properties Tests (:own_data):**
|
|
- User can read properties of their linked member
|
|
- User can update properties of their linked member
|
|
- User cannot read properties of unlinked members
|
|
- Verify relationship traversal works (property.member.user_id)
|
|
|
|
**Read-Only Tests:**
|
|
- User with :read_only can read all properties
|
|
- User with :read_only cannot create/update properties
|
|
|
|
**Normal User Tests:**
|
|
- User with :normal_user can CRUD properties
|
|
|
|
**Admin Tests:**
|
|
- Admin can perform all operations
|
|
|
|
**Test File:** `test/mv/membership/property_policies_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #10: PropertyType Resource Policies
|
|
|
|
**Size:** S (1 day)
|
|
**Dependencies:** #6 (HasPermission check)
|
|
**Can work in parallel:** Yes (parallel with #7, #8, #9)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Add authorization policies to the PropertyType resource. PropertyTypes are admin-managed, but readable by all.
|
|
|
|
**Tasks:**
|
|
|
|
1. Open `lib/mv/membership/property_type.ex`
|
|
2. Add `policies` block
|
|
3. Add read policy: All authenticated users can read (scope :all)
|
|
4. Add write policies: Only admin can create/update/destroy
|
|
5. Use HasPermission check
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] All users can read property types
|
|
- [ ] Only admin can create/update/destroy property types
|
|
- [ ] Policies tested
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Read Access (All Roles):**
|
|
- User with :own_data can read all property types
|
|
- User with :read_only can read all property types
|
|
- User with :normal_user can read all property types
|
|
- User with :admin can read all property types
|
|
|
|
**Write Access (Admin Only):**
|
|
- Non-admin cannot create property type (Forbidden)
|
|
- Non-admin cannot update property type (Forbidden)
|
|
- Non-admin cannot destroy property type (Forbidden)
|
|
- Admin can create property type
|
|
- Admin can update property type
|
|
- Admin can destroy property type
|
|
|
|
**Test File:** `test/mv/membership/property_type_policies_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #11: Page Permission Router Plug
|
|
|
|
**Size:** S (1 day)
|
|
**Dependencies:** #2 (PermissionSets), #6 (HasPermission)
|
|
**Can work in parallel:** Yes (after #2 and #6)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Create a Phoenix plug that checks if the current user has permission to access the requested page/route. This runs before LiveView mounts.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `lib/mv_web/plugs/check_page_permission.ex`
|
|
2. Implement `init/1` and `call/2`
|
|
3. Extract page path from `conn.private[:phoenix_route]` (route template like "/members/:id")
|
|
4. Get user from `conn.assigns[:current_user]`
|
|
5. Get user's role and permission_set_name
|
|
6. Call `PermissionSets.get_permissions/1` to get allowed pages list
|
|
7. Match requested path against allowed patterns:
|
|
- Exact match: "/members" == "/members"
|
|
- Dynamic match: "/members/:id" matches "/members/123"
|
|
- Wildcard: "*" matches everything (admin)
|
|
8. If unauthorized: redirect to "/" with flash error "You don't have permission to access this page."
|
|
9. If authorized: continue (conn not halted)
|
|
10. Add plug to router pipelines (`:browser`, `:require_authenticated_user`)
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Plug checks page permissions from PermissionSets
|
|
- [ ] Static routes work ("/members")
|
|
- [ ] Dynamic routes work ("/members/:id" matches "/members/123")
|
|
- [ ] Wildcard works for admin ("*")
|
|
- [ ] Unauthorized users redirected with flash message
|
|
- [ ] Plug added to appropriate router pipelines
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Static Route Tests:**
|
|
- User with permission for "/members" can access (conn not halted)
|
|
- User without permission for "/members" is denied (conn halted, redirected to "/")
|
|
- Flash error message present after denial
|
|
|
|
**Dynamic Route Tests:**
|
|
- User with "/members/:id" permission can access "/members/123"
|
|
- User with "/members/:id/edit" permission can access "/members/456/edit"
|
|
- User with only "/members/:id" cannot access "/members/123/edit"
|
|
- Pattern matching works correctly
|
|
|
|
**Wildcard Tests:**
|
|
- Admin with "*" permission can access any page
|
|
- Wildcard overrides all other checks
|
|
|
|
**Unauthenticated User Tests:**
|
|
- Nil current_user is redirected to login
|
|
- Login redirect preserves attempted path (optional feature)
|
|
|
|
**Error Handling Tests:**
|
|
- User with invalid permission_set_name is denied
|
|
- User with no role is denied
|
|
- Error is logged but user sees generic message
|
|
|
|
**Test File:** `test/mv_web/plugs/check_page_permission_test.exs`
|
|
|
|
---
|
|
|
|
### Sprint 3: Special Cases & Seeds (Week 3)
|
|
|
|
#### Issue #12: Member Email Validation for Linked Members
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** #7 (Member policies), #8 (User policies)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Implement special validation: Only admins can edit a member's email if that member is linked to a user. This prevents breaking email synchronization.
|
|
|
|
**Tasks:**
|
|
|
|
1. Open `lib/mv/membership/member.ex`
|
|
2. Add custom validation in `validations` block:
|
|
```elixir
|
|
validate changing(:email), on: :update do
|
|
validate &validate_email_change_permission/2
|
|
end
|
|
```
|
|
3. Implement `validate_email_change_permission/2`:
|
|
- Check if member has `user_id` (is linked)
|
|
- If linked: Check if actor has User.update permission with scope :all (admin)
|
|
- If not admin: Return error "Only administrators can change email for members linked to users"
|
|
- If not linked: Allow change
|
|
4. Use `PermissionSets.get_permissions/1` to check admin status
|
|
5. Add tests for all cases
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Non-admin can edit email of unlinked member
|
|
- [ ] Non-admin cannot edit email of linked member
|
|
- [ ] Admin can edit email of linked member
|
|
- [ ] Validation only runs when email changes
|
|
- [ ] Error message is clear and helpful
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Unlinked Member Tests:**
|
|
- User with :normal_user can update email of unlinked member
|
|
- User with :read_only cannot update email (caught by policy, not validation)
|
|
- Validation doesn't block if member.user_id is nil
|
|
|
|
**Linked Member Tests:**
|
|
- User with :normal_user cannot update email of linked member (validation error)
|
|
- Error message mentions "administrators" and "linked to users"
|
|
- User with :admin can update email of linked member (validation passes)
|
|
|
|
**No-Op Tests:**
|
|
- Validation doesn't run if email didn't change
|
|
- Updating other fields (name, address) works normally
|
|
|
|
**Test File:** `test/mv/membership/member_email_validation_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #13: Seed Data - Roles and Default Assignment
|
|
|
|
**Size:** S (1 day)
|
|
**Dependencies:** #2 (PermissionSets), #3 (Role resource)
|
|
**Can work in parallel:** Yes (parallel with #12 after #2 and #3 complete)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Create seed data for 5 roles and assign default "Mitglied" role to existing users. Optionally designate one admin via environment variable.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `priv/repo/seeds/authorization_seeds.exs`
|
|
2. Seed 5 roles using `Ash.Seed.seed!/2` or create actions:
|
|
- **Mitglied:** name="Mitglied", description="Default member role", permission_set_name="own_data", is_system_role=true
|
|
- **Vorstand:** name="Vorstand", description="Board member with read access", permission_set_name="read_only", is_system_role=false
|
|
- **Kassenwart:** name="Kassenwart", description="Treasurer with full member management", permission_set_name="normal_user", is_system_role=false
|
|
- **Buchhaltung:** name="Buchhaltung", description="Accounting with read access", permission_set_name="read_only", is_system_role=false
|
|
- **Admin:** name="Admin", description="Administrator with full access", permission_set_name="admin", is_system_role=false
|
|
3. Make idempotent: Use upsert logic (get by name, update if exists, create if not)
|
|
4. Assign "Mitglied" role to all users without role_id:
|
|
```elixir
|
|
mitglied_role = Ash.get!(Role, name: "Mitglied")
|
|
users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))
|
|
Enum.each(users_without_role, fn user ->
|
|
Ash.update!(user, %{role_id: mitglied_role.id})
|
|
end)
|
|
```
|
|
5. (Optional) Check for `ADMIN_EMAIL` env var, assign Admin role to that user
|
|
6. Add error handling with clear error messages
|
|
7. Add `IO.puts` statements to show progress
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] All 5 roles created with correct permission_set_name
|
|
- [ ] "Mitglied" has is_system_role=true
|
|
- [ ] Existing users without role get "Mitglied" role
|
|
- [ ] Optional: ADMIN_EMAIL user gets Admin role
|
|
- [ ] Seeds are idempotent (can run multiple times)
|
|
- [ ] Error messages are clear
|
|
- [ ] Progress is logged to console
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Role Creation Tests:**
|
|
- After running seeds, 5 roles exist
|
|
- Each role has correct permission_set_name:
|
|
- Mitglied → "own_data"
|
|
- Vorstand → "read_only"
|
|
- Kassenwart → "normal_user"
|
|
- Buchhaltung → "read_only"
|
|
- Admin → "admin"
|
|
- "Mitglied" role has is_system_role=true
|
|
- Other roles have is_system_role=false
|
|
- All permission_set_names are valid (exist in PermissionSets.all_permission_sets/0)
|
|
|
|
**User Assignment Tests:**
|
|
- Users without role_id are assigned "Mitglied" role
|
|
- Users who already have role_id are not changed
|
|
- Count of users with "Mitglied" role increases by number of previously unassigned users
|
|
|
|
**Idempotency Tests:**
|
|
- Running seeds twice doesn't create duplicate roles
|
|
- Each role name appears exactly once
|
|
- Running seeds twice doesn't reassign users who already have roles
|
|
|
|
**Optional Admin Tests:**
|
|
- If ADMIN_EMAIL set, user with that email gets Admin role
|
|
- If ADMIN_EMAIL not set, no error occurs
|
|
- If email doesn't exist, error is logged but seeds continue
|
|
|
|
**Error Handling Tests:**
|
|
- Seeds fail gracefully if invalid permission_set_name provided
|
|
- Error message indicates which permission_set_name is invalid
|
|
|
|
**Test File:** `test/seeds/authorization_seeds_test.exs`
|
|
|
|
---
|
|
|
|
### Sprint 4: UI & Integration (Week 4)
|
|
|
|
#### Issue #14: UI Authorization Helper Module
|
|
|
|
**Size:** M (2-3 days)
|
|
**Dependencies:** #2 (PermissionSets), #6 (HasPermission), #13 (Seeds - for testing)
|
|
**Assignable to:** Backend Developer + Frontend Developer
|
|
|
|
**Description:**
|
|
|
|
Create helper functions for UI-level authorization checks. These will be used in LiveView templates to conditionally render buttons, links, and sections based on user permissions.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create `lib/mv_web/authorization.ex`
|
|
2. Implement `can?/3` for resource-level checks:
|
|
```elixir
|
|
def can?(user, action, resource) when is_atom(resource)
|
|
# Returns true if user has permission for action on resource
|
|
# e.g., can?(current_user, :create, Mv.Membership.Member)
|
|
```
|
|
3. Implement `can?/3` for record-level checks:
|
|
```elixir
|
|
def can?(user, action, %resource{} = record)
|
|
# Returns true if user has permission for action on specific record
|
|
# Applies scope checking (own, linked, all)
|
|
# e.g., can?(current_user, :update, member)
|
|
```
|
|
4. Implement `can_access_page?/2`:
|
|
```elixir
|
|
def can_access_page?(user, page_path)
|
|
# Returns true if user's permission set includes page
|
|
# e.g., can_access_page?(current_user, "/members/new")
|
|
```
|
|
5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission)
|
|
6. All functions handle nil user gracefully (return false)
|
|
7. Implement resource-specific scope checking (Member vs Property for :linked)
|
|
8. Add comprehensive `@doc` with template examples
|
|
9. Import helper in `mv_web.ex` `html_helpers` section
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] `can?/3` works for resource atoms
|
|
- [ ] `can?/3` works for record structs with scope checking
|
|
- [ ] `can_access_page?/2` matches page patterns correctly
|
|
- [ ] Nil user always returns false
|
|
- [ ] Invalid permission_set_name returns false (not crash)
|
|
- [ ] Helper imported in `mv_web.ex`
|
|
- [ ] Comprehensive documentation with examples
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**can?/3 with Resource Atom:**
|
|
- Returns true when user has permission for resource+action
|
|
- Admin can create Member (returns true)
|
|
- Read-only cannot create Member (returns false)
|
|
- Nil user returns false
|
|
|
|
**can?/3 with Record Struct - Scope :all:**
|
|
- Admin can update any member (returns true for any record)
|
|
- Normal user can update any member (scope :all)
|
|
|
|
**can?/3 with Record Struct - Scope :own:**
|
|
- User can update own User record (record.id == user.id)
|
|
- User cannot update other User record (record.id != user.id)
|
|
|
|
**can?/3 with Record Struct - Scope :linked:**
|
|
- User can update linked Member (member.user_id == user.id)
|
|
- User cannot update unlinked Member
|
|
- User can update Property of linked Member (property.member.user_id == user.id)
|
|
- User cannot update Property of unlinked Member
|
|
- Scope checking is resource-specific (Member vs Property)
|
|
|
|
**can_access_page?/2:**
|
|
- User with page in list can access (returns true)
|
|
- User without page in list cannot access (returns false)
|
|
- Dynamic routes match correctly ("/members/:id" matches "/members/123")
|
|
- Admin wildcard "*" matches any page
|
|
- Nil user returns false
|
|
|
|
**Error Handling:**
|
|
- User without role returns false
|
|
- User with invalid permission_set_name returns false (no crash)
|
|
- Handles missing fields gracefully
|
|
|
|
**Test File:** `test/mv_web/authorization_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #15: Admin UI for Role Management
|
|
|
|
**Size:** M (2 days)
|
|
**Dependencies:** #14 (UI Authorization Helper)
|
|
**Assignable to:** Frontend Developer
|
|
|
|
**Description:**
|
|
|
|
Update Role management LiveViews to use authorization helpers for conditional rendering. Add UI polish.
|
|
|
|
**Tasks:**
|
|
|
|
1. Open `lib/mv_web/live/role_live/index.ex`
|
|
2. Add authorization checks for "New Role" button:
|
|
```heex
|
|
<%= if can?(@current_user, :create, Mv.Authorization.Role) do %>
|
|
<.link patch={~p"/admin/roles/new"}>New Role</.link>
|
|
<% end %>
|
|
```
|
|
3. Add authorization checks for "Edit" and "Delete" buttons in table
|
|
4. Gray out/hide "Delete" for system roles
|
|
5. Update `show.ex` to hide edit button if user can't update
|
|
6. Add role badge/pill for system roles
|
|
7. Add permission_set_name badge with color coding:
|
|
- own_data → gray
|
|
- read_only → blue
|
|
- normal_user → green
|
|
- admin → red
|
|
8. Test UI with different user roles
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] Only admin sees "New Role" button
|
|
- [ ] Only admin sees "Edit" and "Delete" buttons
|
|
- [ ] System roles have visual indicator
|
|
- [ ] Delete button hidden/disabled for system roles
|
|
- [ ] Permission set badges are color-coded
|
|
- [ ] UI tested with all role types
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Admin View:**
|
|
- Admin sees "New Role" button
|
|
- Admin sees "Edit" buttons for all roles
|
|
- Admin sees "Delete" buttons for non-system roles
|
|
- Admin does not see "Delete" button for system roles
|
|
|
|
**Non-Admin View:**
|
|
- Non-admin does not see "New Role" button (redirected by page permission plug anyway)
|
|
- Non-admin cannot access /admin/roles (caught by plug)
|
|
|
|
**Visual Tests:**
|
|
- System roles have badge
|
|
- Permission set names are color-coded
|
|
- UI renders correctly
|
|
|
|
**Test File:** `test/mv_web/live/role_live_authorization_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #16: Apply UI Authorization to Existing LiveViews
|
|
|
|
**Size:** L (3 days)
|
|
**Dependencies:** #14 (UI Authorization Helper)
|
|
**Can work in parallel:** Yes (parallel with #15)
|
|
**Assignable to:** Frontend Developer
|
|
|
|
**Description:**
|
|
|
|
Update all existing LiveViews (Member, User, Property, PropertyType) to use authorization helpers for conditional rendering.
|
|
|
|
**Tasks:**
|
|
|
|
1. **Member LiveViews:**
|
|
- Index: Hide "New Member" if can't create
|
|
- Index: Hide "Edit" and "Delete" buttons per record if can't update/destroy
|
|
- Show: Hide "Edit" button if can't update record
|
|
- Form: Should not be accessible (caught by page permission plug)
|
|
|
|
2. **User LiveViews:**
|
|
- Index: Only show if user is admin
|
|
- Show: Only show other users if admin, always show own profile
|
|
- Edit: Only allow editing own profile or admin editing anyone
|
|
|
|
3. **Property LiveViews:**
|
|
- Similar to Member (hide create/edit/delete based on permissions)
|
|
|
|
4. **PropertyType LiveViews:**
|
|
- All users can view
|
|
- Only admin can create/edit/delete
|
|
|
|
5. **Navbar:**
|
|
- Only show "Admin" dropdown if user has admin permission set
|
|
- Only show "Roles" link if can access /admin/roles
|
|
- Only show "Members" link if can access /members
|
|
- Always show "Profile" link
|
|
|
|
6. Test all views with all 5 role types
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] All LiveViews use `can?/3` for conditional rendering
|
|
- [ ] Buttons/links hidden when user lacks permission
|
|
- [ ] Navbar shows appropriate links per role
|
|
- [ ] Tested with all 5 roles (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
|
|
- [ ] UI is clean (no awkward empty spaces from hidden buttons)
|
|
|
|
**Test Strategy (TDD):**
|
|
|
|
**Member Index - Mitglied (own_data):**
|
|
- Does not see "New Member" button
|
|
- Does not see list of members (empty or filtered)
|
|
- Can only see own linked member if navigated directly
|
|
|
|
**Member Index - Vorstand (read_only):**
|
|
- Sees full member list
|
|
- Does not see "New Member" button
|
|
- Does not see "Edit" or "Delete" buttons
|
|
|
|
**Member Index - Kassenwart (normal_user):**
|
|
- Sees full member list
|
|
- Sees "New Member" button
|
|
- Sees "Edit" button for all members
|
|
- Does not see "Delete" button (not in permission set)
|
|
|
|
**Member Index - Admin:**
|
|
- Sees everything (New, Edit, Delete)
|
|
|
|
**Navbar Tests (all roles):**
|
|
- Mitglied: Sees only "Home" and "Profile"
|
|
- Vorstand: Sees "Home", "Members" (read-only), "Profile"
|
|
- Kassenwart: Sees "Home", "Members", "Properties", "Profile"
|
|
- Buchhaltung: Sees "Home", "Members" (read-only), "Profile"
|
|
- Admin: Sees "Home", "Members", "Properties", "Property Types", "Admin", "Profile"
|
|
|
|
**Test Files:**
|
|
- `test/mv_web/live/member_live_authorization_test.exs`
|
|
- `test/mv_web/live/user_live_authorization_test.exs`
|
|
- `test/mv_web/live/property_live_authorization_test.exs`
|
|
- `test/mv_web/live/property_type_live_authorization_test.exs`
|
|
- `test/mv_web/components/navbar_authorization_test.exs`
|
|
|
|
---
|
|
|
|
#### Issue #17: Integration Tests - Complete User Journeys
|
|
|
|
**Size:** L (3 days)
|
|
**Dependencies:** All above (full system must be functional)
|
|
**Assignable to:** Backend Developer
|
|
|
|
**Description:**
|
|
|
|
Write comprehensive integration tests that follow complete user journeys for each role. These tests verify that policies, UI helpers, and page permissions all work together correctly.
|
|
|
|
**Tasks:**
|
|
|
|
1. Create test file for each role:
|
|
- `test/integration/mitglied_journey_test.exs`
|
|
- `test/integration/vorstand_journey_test.exs`
|
|
- `test/integration/kassenwart_journey_test.exs`
|
|
- `test/integration/buchhaltung_journey_test.exs`
|
|
- `test/integration/admin_journey_test.exs`
|
|
|
|
2. Each test follows a complete user flow:
|
|
- Login as user with role
|
|
- Navigate to allowed pages
|
|
- Attempt to access forbidden pages
|
|
- Perform allowed actions
|
|
- Attempt forbidden actions
|
|
- Verify UI shows/hides appropriate elements
|
|
|
|
3. Test cross-cutting concerns:
|
|
- Email synchronization (Member <-> User)
|
|
- User-Member linking (admin only)
|
|
- System role protection
|
|
|
|
**Acceptance Criteria:**
|
|
|
|
- [ ] One integration test per role (5 total)
|
|
- [ ] Tests cover complete user journeys
|
|
- [ ] Tests verify both backend (policies) and frontend (UI helpers)
|
|
- [ ] Tests verify page permissions
|
|
- [ ] Tests verify special cases (email, linking, system roles)
|
|
- [ ] All tests pass
|
|
|
|
**Test Strategy:**
|
|
|
|
**Mitglied Journey:**
|
|
1. Login as Mitglied user
|
|
2. Can access home page and profile
|
|
3. Cannot access /members (redirected)
|
|
4. Cannot access /admin/roles (redirected)
|
|
5. Can view own linked member via direct URL
|
|
6. Can update own member data
|
|
7. Cannot update unlinked member
|
|
8. Can update own user credentials
|
|
9. Cannot view other users
|
|
|
|
**Vorstand Journey:**
|
|
1. Login as Vorstand user
|
|
2. Can access /members (reads all members)
|
|
3. Cannot create member (no button in UI, backend forbids)
|
|
4. Cannot edit member (no button in UI, backend forbids)
|
|
5. Can access /members/:id (read-only view)
|
|
6. Cannot access /members/:id/edit (page permission denies)
|
|
7. Can update own credentials
|
|
8. Cannot access /admin/roles
|
|
|
|
**Kassenwart Journey:**
|
|
1. Login as Kassenwart user
|
|
2. Can access /members
|
|
3. Can create new member
|
|
4. Can edit any member (except email if linked - see special case)
|
|
5. Cannot delete member
|
|
6. Can manage properties
|
|
7. Cannot manage property types (read-only)
|
|
8. Cannot access /admin/roles
|
|
|
|
**Buchhaltung Journey:**
|
|
1. Login as Buchhaltung user
|
|
2. Can access /members (read-only)
|
|
3. Cannot create/edit members
|
|
4. Can view properties (read-only)
|
|
5. Same restrictions as Vorstand
|
|
|
|
**Admin Journey:**
|
|
1. Login as Admin user
|
|
2. Can access all pages (wildcard permission)
|
|
3. Can CRUD all resources
|
|
4. Can edit member email even if linked
|
|
5. Can manage roles
|
|
6. Cannot delete system roles (backend prevents)
|
|
7. Can link/unlink users and members
|
|
8. Can edit any user's credentials
|
|
|
|
**Special Cases Tests:**
|
|
- Member email editing (admin vs non-admin for linked member)
|
|
- System role deletion (always fails)
|
|
- User without role (access denied everywhere)
|
|
- User with invalid permission_set_name (access denied)
|
|
|
|
**Test Files:**
|
|
- `test/integration/mitglied_journey_test.exs`
|
|
- `test/integration/vorstand_journey_test.exs`
|
|
- `test/integration/kassenwart_journey_test.exs`
|
|
- `test/integration/buchhaltung_journey_test.exs`
|
|
- `test/integration/admin_journey_test.exs`
|
|
- `test/integration/special_cases_test.exs`
|
|
|
|
---
|
|
|
|
## Dependencies & Parallelization
|
|
|
|
### Dependency Graph
|
|
|
|
```
|
|
┌──────────────────┐
|
|
│ Issue #1 │
|
|
│ Auth Domain │
|
|
│ + Role Res │
|
|
└────────┬─────────┘
|
|
│
|
|
┌────────────┴────────────┐
|
|
│ │
|
|
┌───────▼────────┐ ┌───────▼────────┐
|
|
│ Issue #2 │ │ Issue #3 │
|
|
│ PermissionSets│ │ Role CRUD │
|
|
│ Module │ │ LiveViews │
|
|
└───────┬────────┘ └────────────────┘
|
|
│
|
|
│
|
|
└────────────┬────────────┘
|
|
│
|
|
┌────────▼─────────┐
|
|
│ Issue #6 │
|
|
│ HasPermission │
|
|
│ Policy Check │
|
|
└────────┬─────────┘
|
|
│
|
|
┌────────────────────┼─────────────────────┐
|
|
│ │ │
|
|
┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
|
│ Issue #7 │ │ Issue #8 │ │ Issue #11 │
|
|
│ Member │ │ User │ │ Page Plug │
|
|
│ Policies │ │ Policies │ └──────┬──────┘
|
|
└────┬─────┘ └──────┬──────┘ │
|
|
│ │ │
|
|
┌────▼─────┐ ┌──────▼──────┐ │
|
|
│ Issue #9 │ │ Issue #10 │ │
|
|
│ Property │ │ PropType │ │
|
|
│ Policies │ │ Policies │ │
|
|
└────┬─────┘ └──────┬──────┘ │
|
|
│ │ │
|
|
└────────────────────┴─────────────────────┘
|
|
│
|
|
┌────────────┴────────────┐
|
|
│ │
|
|
┌───────▼────────┐ ┌───────▼────────┐
|
|
│ Issue #12 │ │ Issue #13 │
|
|
│ Email Valid │ │ Seeds │
|
|
└───────┬────────┘ └───────┬────────┘
|
|
│ │
|
|
└────────────┬────────────┘
|
|
│
|
|
┌────────▼─────────┐
|
|
│ Issue #14 │
|
|
│ UI Helper │
|
|
└────────┬─────────┘
|
|
│
|
|
┌────────────┴────────────┐
|
|
│ │
|
|
┌───────▼────────┐ ┌───────▼────────┐
|
|
│ Issue #15 │ │ Issue #16 │
|
|
│ Admin UI │ │ Apply UI Auth│
|
|
└───────┬────────┘ └───────┬────────┘
|
|
│ │
|
|
└────────────┬────────────┘
|
|
│
|
|
┌────────▼─────────┐
|
|
│ Issue #17 │
|
|
│ Integration │
|
|
│ Tests │
|
|
└──────────────────┘
|
|
```
|
|
|
|
### Parallelization Opportunities
|
|
|
|
**After Issue #1:**
|
|
- Issues #2 and #3 can run in parallel
|
|
|
|
**After Issue #6:**
|
|
- Issues #7, #8, #9, #10, #11 can ALL run in parallel (5 issues!)
|
|
- This is the main parallelization opportunity
|
|
|
|
**After Issues #7-#11:**
|
|
- Issues #12 and #13 can run in parallel
|
|
|
|
**After Issue #14:**
|
|
- Issues #15 and #16 can run in parallel
|
|
|
|
### Sprint Breakdown
|
|
|
|
| Sprint | Issues | Duration | Can Parallelize |
|
|
|--------|--------|----------|-----------------|
|
|
| Sprint 1 | #1, #2, #3 | Week 1 | #2 and #3 after #1 |
|
|
| Sprint 2 | #6, #7, #8, #9, #10, #11 | Week 2 | #7-#11 after #6 (5 parallel!) |
|
|
| Sprint 3 | #12, #13 | Week 3 | Yes (2 parallel) |
|
|
| Sprint 4 | #14, #15, #16, #17 | Week 4 | #15 & #16 after #14 |
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Test-Driven Development Process
|
|
|
|
**For Every Issue:**
|
|
1. Read acceptance criteria
|
|
2. Write failing tests covering all criteria
|
|
3. Verify tests fail (red)
|
|
4. Implement minimum code to pass
|
|
5. Verify tests pass (green)
|
|
6. Refactor if needed
|
|
7. All tests still pass
|
|
|
|
### Test Coverage Goals
|
|
|
|
**Total Estimated Tests: 180+**
|
|
|
|
| Test Type | Count | Coverage |
|
|
|-----------|-------|----------|
|
|
| Unit Tests | ~80 | PermissionSets module, Policy checks, Scope logic, UI helpers |
|
|
| Integration Tests | ~70 | Cross-resource authorization, Special cases, Email validation |
|
|
| LiveView Tests | ~25 | UI rendering, Page permissions, Conditional elements |
|
|
| E2E Journey Tests | ~5 | Complete user flows (one per role) |
|
|
|
|
### What to Test (Focus on Behavior)
|
|
|
|
**DO Test:**
|
|
- Permission lookups return correct results
|
|
- Policies allow/deny actions correctly
|
|
- Scope filters work (own, linked, all)
|
|
- UI elements show/hide based on permissions
|
|
- Page access is controlled
|
|
- Special cases work (email, system roles)
|
|
- Error handling (no crashes)
|
|
|
|
**DON'T Test:**
|
|
- Database schema existence
|
|
- Table columns (Ash generates these)
|
|
- Implementation details
|
|
- Private functions (test through public API)
|
|
|
|
### Test Files Structure
|
|
|
|
```
|
|
test/
|
|
├── mv/
|
|
│ └── authorization/
|
|
│ ├── permission_sets_test.exs # Issue #2
|
|
│ ├── role_test.exs # Issue #1 (smoke)
|
|
│ └── checks/
|
|
│ └── has_permission_test.exs # Issue #6
|
|
├── mv/accounts/
|
|
│ └── user_policies_test.exs # Issue #8
|
|
├── mv/membership/
|
|
│ ├── member_policies_test.exs # Issue #7
|
|
│ ├── member_email_validation_test.exs # Issue #12
|
|
│ ├── property_policies_test.exs # Issue #9
|
|
│ └── property_type_policies_test.exs # Issue #10
|
|
├── mv_web/
|
|
│ ├── authorization_test.exs # Issue #14
|
|
│ ├── plugs/
|
|
│ │ └── check_page_permission_test.exs # Issue #11
|
|
│ └── live/
|
|
│ ├── role_live_test.exs # Issue #3
|
|
│ ├── role_live_authorization_test.exs # Issue #15
|
|
│ ├── member_live_authorization_test.exs # Issue #16
|
|
│ ├── user_live_authorization_test.exs # Issue #16
|
|
│ ├── property_live_authorization_test.exs # Issue #16
|
|
│ └── property_type_live_authorization_test.exs # Issue #16
|
|
├── integration/
|
|
│ ├── mitglied_journey_test.exs # Issue #17
|
|
│ ├── vorstand_journey_test.exs # Issue #17
|
|
│ ├── kassenwart_journey_test.exs # Issue #17
|
|
│ ├── buchhaltung_journey_test.exs # Issue #17
|
|
│ ├── admin_journey_test.exs # Issue #17
|
|
│ └── special_cases_test.exs # Issue #17
|
|
└── seeds/
|
|
└── authorization_seeds_test.exs # Issue #13
|
|
```
|
|
|
|
---
|
|
|
|
## Migration & Rollback
|
|
|
|
### Database Migrations
|
|
|
|
**Issue #1 creates one migration:**
|
|
|
|
```elixir
|
|
# priv/repo/migrations/TIMESTAMP_add_authorization.exs
|
|
defmodule Mv.Repo.Migrations.AddAuthorization do
|
|
use Ecto.Migration
|
|
|
|
def up do
|
|
# Create roles table
|
|
create table(:roles, primary_key: false) do
|
|
add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
|
|
add :name, :string, null: false
|
|
add :description, :text
|
|
add :permission_set_name, :string, null: false
|
|
add :is_system_role, :boolean, default: false, null: false
|
|
|
|
timestamps()
|
|
end
|
|
|
|
create unique_index(:roles, [:name])
|
|
create index(:roles, [:permission_set_name])
|
|
|
|
# Add role_id to users table
|
|
alter table(:users) do
|
|
add :role_id, references(:roles, type: :binary_id, on_delete: :restrict)
|
|
end
|
|
|
|
create index(:users, [:role_id])
|
|
end
|
|
|
|
def down do
|
|
drop index(:users, [:role_id])
|
|
|
|
alter table(:users) do
|
|
remove :role_id
|
|
end
|
|
|
|
drop table(:roles)
|
|
end
|
|
end
|
|
```
|
|
|
|
### Data Migration (Seeds)
|
|
|
|
**After migration applied:**
|
|
|
|
Run seeds to create roles and assign defaults:
|
|
|
|
```bash
|
|
mix run priv/repo/seeds/authorization_seeds.exs
|
|
```
|
|
|
|
### Rollback Plan
|
|
|
|
**If issues discovered in production:**
|
|
|
|
1. **Immediate Rollback:**
|
|
- Set `ENABLE_RBAC=false` environment variable
|
|
- Restart application
|
|
- Old authorization system takes over instantly
|
|
|
|
2. **Database Rollback (if needed):**
|
|
```bash
|
|
mix ecto.rollback --step 1
|
|
```
|
|
- Removes `role_id` from users
|
|
- Removes `roles` table
|
|
- Existing auth untouched
|
|
|
|
3. **Code Rollback:**
|
|
- Revert Git commit
|
|
- Redeploy previous version
|
|
|
|
**Rollback Safety:**
|
|
- No existing tables modified (only additions)
|
|
- Feature flag allows instant disable
|
|
- Old auth code remains in place until RBAC proven stable
|
|
|
|
---
|
|
|
|
## Risk Management
|
|
|
|
### Identified Risks
|
|
|
|
| Risk | Probability | Impact | Mitigation |
|
|
|------|-------------|--------|------------|
|
|
| **Policy order issues** | Medium | High | Clear documentation, strict order enforcement, integration tests verify policies work together |
|
|
| **Scope filter errors** | Medium | High | TDD approach, extensive scope tests (own/linked/all), test with all resource types |
|
|
| **UI/Policy divergence** | Low | Medium | UI helpers use same PermissionSets module as policies, shared logic, integration tests verify consistency |
|
|
| **Breaking existing auth** | Low | High | Feature flag allows instant rollback, parallel systems until proven, gradual rollout |
|
|
| **User without role edge case** | Low | Medium | Default "Mitglied" role assigned in seeds, validation on User.create, tests cover nil role |
|
|
| **Invalid permission_set_name** | Low | Low | Validation on Role resource, tests cover invalid names, error handling throughout |
|
|
| **Performance (not a concern)** | Very Low | Low | Hardcoded permissions are < 1 microsecond, no DB queries, no cache needed |
|
|
|
|
### Edge Cases Handled
|
|
|
|
**User without role:**
|
|
- Default: Access denied (no permissions)
|
|
- Seeds assign "Mitglied" to all existing users
|
|
- New users must be assigned role on creation
|
|
|
|
**Invalid permission_set_name:**
|
|
- Role validation prevents creation
|
|
- Runtime checks handle gracefully (return false/error, no crash)
|
|
- Error logged for debugging
|
|
|
|
**System role protection:**
|
|
- Cannot delete role with `is_system_role=true`
|
|
- UI hides delete button
|
|
- Backend validation prevents deletion
|
|
- "Mitglied" is system role by default
|
|
|
|
**Linked member email:**
|
|
- Custom validation on Member resource
|
|
- Only admins can edit if member.user_id present
|
|
- Prevents breaking email synchronization
|
|
|
|
**Missing actor context:**
|
|
- All policies check for actor presence
|
|
- Missing actor = access denied
|
|
- No crashes, graceful error handling
|
|
|
|
### Performance Considerations
|
|
|
|
**No concerns for MVP:**
|
|
- Hardcoded permissions are pure function calls
|
|
- No database queries for permission checks
|
|
- Pattern matching on small lists (< 50 items total)
|
|
- Typical check: < 1 microsecond
|
|
- Can handle 10,000+ requests/second easily
|
|
|
|
**Future considerations (Phase 3):**
|
|
- If migrating to database-backed: add ETS cache
|
|
- Cache invalidation on role/permission changes
|
|
- Database indexes on permission tables
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
**MVP is successful when:**
|
|
|
|
- [ ] All 15 issues completed
|
|
- [ ] All 180+ tests passing
|
|
- [ ] Zero linter errors
|
|
- [ ] Manual testing completed for all 5 roles
|
|
- [ ] Integration tests verify complete user journeys
|
|
- [ ] Feature flag tested (on/off states)
|
|
- [ ] Documentation complete
|
|
- [ ] Code review approved
|
|
- [ ] Deployed to staging and verified
|
|
- [ ] Performance verified (< 100ms per page load)
|
|
- [ ] No authorization bypasses found in security review
|
|
|
|
**Ready for Production when:**
|
|
|
|
- [ ] 1 week in staging with no critical issues
|
|
- [ ] All stakeholders have tested their role types
|
|
- [ ] Rollback plan tested
|
|
- [ ] Monitoring/alerting configured
|
|
- [ ] Runbook created for common issues
|
|
|
|
---
|
|
|
|
## Next Steps After MVP
|
|
|
|
**Phase 2: Field-Level Permissions (Future - 2-3 weeks)**
|
|
|
|
- Extend PermissionSets with `:fields` key
|
|
- Implement Ash Calculations to filter readable fields
|
|
- Implement Custom Validations for writable fields
|
|
- No database changes needed
|
|
- See [Architecture Document](./roles-and-permissions-architecture.md) for details
|
|
|
|
**Phase 3: Database-Backed Permissions (Future - 3-4 weeks)**
|
|
|
|
- Create `permission_sets`, `permission_set_resources`, `permission_set_pages` tables
|
|
- Replace hardcoded PermissionSets module with DB queries
|
|
- Implement ETS cache for performance
|
|
- Allow runtime permission configuration
|
|
- See [Architecture Document](./roles-and-permissions-architecture.md) for migration strategy
|
|
|
|
---
|
|
|
|
## Document History
|
|
|
|
| Version | Date | Author | Changes |
|
|
|---------|------|--------|---------|
|
|
| 1.0 | 2025-01-12 | AI Assistant | Initial version with DB-backed permissions |
|
|
| 2.0 | 2025-01-13 | AI Assistant | Complete rewrite for hardcoded MVP, removed all V1 references, fixed Buchhaltung inconsistency |
|
|
|
|
---
|
|
|
|
## Appendix
|
|
|
|
### Glossary
|
|
|
|
- **Permission Set:** A named collection of resource and page permissions (e.g., "admin", "read_only")
|
|
- **Role:** A database entity that links users to a permission set
|
|
- **Scope:** The range of records a permission applies to (:own, :linked, :all)
|
|
- **Actor:** The currently authenticated user in Ash authorization context
|
|
- **System Role:** A role that cannot be deleted (is_system_role=true)
|
|
|
|
### Key Files
|
|
|
|
- `lib/mv/authorization/permission_sets.ex` - Core permissions logic
|
|
- `lib/mv/authorization/checks/has_permission.ex` - Ash policy check
|
|
- `lib/mv_web/authorization.ex` - UI helper functions
|
|
- `lib/mv_web/plugs/check_page_permission.ex` - Page access control
|
|
- `priv/repo/seeds/authorization_seeds.exs` - Role seed data
|
|
|
|
### Useful Commands
|
|
|
|
```bash
|
|
# Run all authorization tests
|
|
mix test test/mv/authorization
|
|
|
|
# Run integration tests only
|
|
mix test test/integration
|
|
|
|
# Run with coverage
|
|
mix test --cover
|
|
|
|
# Generate migrations after Ash resource changes
|
|
mix ash.codegen
|
|
|
|
# Run seeds
|
|
mix run priv/repo/seeds/authorization_seeds.exs
|
|
|
|
# Check for linter errors
|
|
mix credo --strict
|
|
```
|
|
|
|
---
|
|
|
|
**End of Implementation Plan**
|
|
|