57 KiB
Roles and Permissions - Implementation Plan (MVP)
Version: 2.0 (Clean Rewrite)
Date: 2025-01-13
Status: Ready for Implementation
Related Documents:
- Overview - High-level concepts
- Architecture - Technical specification
Table of Contents
- Executive Summary
- MVP Scope
- Implementation Strategy
- Issue Breakdown
- Dependencies & Parallelization
- Testing Strategy
- Migration & Rollback
- 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
rolestable - Zero performance concerns - all permission checks are in-memory function calls
What's NOT in MVP
Deferred to Phase 3 (Future):
- Issue #4:
PermissionSetResourcedatabase table - Issue #5:
PermissionSetPagedatabase table - Issue #18: ETS Permission Cache
- Database-backed dynamic permissions
The Four Permission Sets
Hardcoded in Mv.Authorization.PermissionSets module:
- own_data - User can only access their own data (default for "Mitglied")
- read_only - Read access to all members/properties (for "Vorstand", "Buchhaltung")
- normal_user - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart")
- admin - Unrestricted access including user/role management (for "Admin")
The Five Roles
Stored in database roles table, each referencing a permission_set_name:
- Mitglied → "own_data" (is_system_role=true, default)
- Vorstand → "read_only"
- Kassenwart → "normal_user"
- Buchhaltung → "read_only"
- 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:
- Write failing tests first
- Implement minimum code to pass tests
- Refactor if needed
- 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:
- Create
lib/mv/authorization/directory - Create
lib/mv/authorization/role.exAsh 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
- Add validation:
permission_set_namemust exist inPermissionSets.all_permission_sets/0 - Add
role_id(UUID, nullable, foreign key) touserstable - Add
belongs_to :rolerelationship in User resource - Run
mix ash.codegento generate migrations - 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_roleflag 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
:roleinrelationships()
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:
- Create
lib/mv/authorization/permission_sets.ex - Define module with
@moduledocexplaining the 4 permission sets - Define types:
@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()] } - Implement
get_permissions/1for each of the 4 permission sets - Implement
all_permission_sets/0returning[:own_data, :read_only, :normal_user, :admin] - Implement
valid_permission_set?/1checking if name is in the list - Implement
permission_set_name_to_atom/1with error handling - Add comprehensive
@docexamples 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/1returns correct structure for each setvalid_permission_set?/1works for atoms and stringspermission_set_name_to_atom/1handles errors gracefully- All functions have
@docand@spec - Code is readable and well-commented
Test Strategy (TDD):
Structure Tests:
get_permissions(:own_data)returns map with:resourcesand:pageskeys- 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_dataallows User read/update with scope :own:own_dataallows Member/Property read/update with scope :linked:read_onlyallows Member/Property read with scope :all:read_onlydoes NOT allow Member/Property create/update/destroy:normal_userallows Member/Property full CRUD with scope :all:adminallows everything with scope :all:adminhas wildcard page permission "*"
Validation Tests:
valid_permission_set?("own_data")returns truevalid_permission_set?(:admin)returns truevalid_permission_set?("invalid")returns falsepermission_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:
- Create
lib/mv_web/live/role_live/directory - Implement
index.ex- List all roles - Implement
show.ex- View role details - Implement
form.ex- Create/Edit role form component - Add routes in
router.exunder/adminscope - Create table component showing: name, description, permission_set_name, is_system_role
- Add form validation for
permission_set_name(dropdown with 4 options) - Prevent deletion of system roles (UI + backend)
- Add flash messages for success/error
- 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_nameis 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_nameshows error - Update role name succeeds
- Update system role's
permission_set_namesucceeds - 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:
- Create
lib/mv/authorization/checks/has_permission.ex - Implement
use Ash.Policy.Check - Implement
describe/1- returns human-readable description - 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
- Extract
- Implement
apply_scope/3helper::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!)
- Member:
- 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}
- No actor →
- Add logging for authorization failures (debug level)
- Add comprehensive
@docwith examples
Acceptance Criteria:
- Check module implements
Ash.Policy.Checkbehavior match?/3correctly evaluates permissions from PermissionSets- Scope filters work correctly (:all, :own, :linked)
:linkedscope 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:
- Open
lib/mv/membership/member.ex - Add
policiesblock at top of resource (before actions) - Configure policy to
Mv.Authorization.Checks.HasPermission - 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
- Add special policy: Allow user to read/update their linked member (before general policy)
policy action_type(:read) do authorize_if expr(user_id == ^actor(:id)) end - Ensure policies load actor with
:rolerelationship preloaded - Test policies with different actors
Policy Order (Critical!):
- Allow user to access their own linked member (most specific)
- Check HasPermission (general authorization)
- 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:
- Open
lib/mv/accounts/user.ex - Add
policiesblock - Add special policy: Allow user to always access their own account (before general policy)
policy action_type([:read, :update]) do authorize_if expr(id == ^actor(:id)) end - Add general policy: Check HasPermission for all actions
- Ensure :destroy is admin-only (via HasPermission)
- Preload :role relationship for actor
Policy Order:
- Allow user to read/update own account (id == actor.id)
- Check HasPermission (for admin operations)
- 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:
- Open
lib/mv/membership/property.ex - Add
policiesblock - Add special policy: Allow user to read/update properties of their linked member
policy action_type([:read, :update]) do authorize_if expr(member.user_id == ^actor(:id)) end - Add general policy: Check HasPermission
- Ensure Property preloads :member relationship for scope checks
- Preload :role relationship for actor
Policy Order:
- Allow user to read/update properties of linked member
- Check HasPermission
- 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:
- Open
lib/mv/membership/property_type.ex - Add
policiesblock - Add read policy: All authenticated users can read (scope :all)
- Add write policies: Only admin can create/update/destroy
- 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:
- Create
lib/mv_web/plugs/check_page_permission.ex - Implement
init/1andcall/2 - Extract page path from
conn.private[:phoenix_route](route template like "/members/:id") - Get user from
conn.assigns[:current_user] - Get user's role and permission_set_name
- Call
PermissionSets.get_permissions/1to get allowed pages list - Match requested path against allowed patterns:
- Exact match: "/members" == "/members"
- Dynamic match: "/members/:id" matches "/members/123"
- Wildcard: "*" matches everything (admin)
- If unauthorized: redirect to "/" with flash error "You don't have permission to access this page."
- If authorized: continue (conn not halted)
- 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:
- Open
lib/mv/membership/member.ex - Add custom validation in
validationsblock:validate changing(:email), on: :update do validate &validate_email_change_permission/2 end - 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
- Check if member has
- Use
PermissionSets.get_permissions/1to check admin status - 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:
- Create
priv/repo/seeds/authorization_seeds.exs - Seed 5 roles using
Ash.Seed.seed!/2or 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
- Make idempotent: Use upsert logic (get by name, update if exists, create if not)
- Assign "Mitglied" role to all users without role_id:
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) - (Optional) Check for
ADMIN_EMAILenv var, assign Admin role to that user - Add error handling with clear error messages
- Add
IO.putsstatements 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:
- Create
lib/mv_web/authorization.ex - Implement
can?/3for resource-level checks: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) - Implement
can?/3for record-level checks: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) - Implement
can_access_page?/2: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") - All functions use
PermissionSets.get_permissions/1(same logic as HasPermission) - All functions handle nil user gracefully (return false)
- Implement resource-specific scope checking (Member vs Property for :linked)
- Add comprehensive
@docwith template examples - Import helper in
mv_web.exhtml_helperssection
Acceptance Criteria:
can?/3works for resource atomscan?/3works for record structs with scope checkingcan_access_page?/2matches 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:
- Open
lib/mv_web/live/role_live/index.ex - Add authorization checks for "New Role" button:
<%= if can?(@current_user, :create, Mv.Authorization.Role) do %> <.link patch={~p"/admin/roles/new"}>New Role</.link> <% end %> - Add authorization checks for "Edit" and "Delete" buttons in table
- Gray out/hide "Delete" for system roles
- Update
show.exto hide edit button if user can't update - Add role badge/pill for system roles
- Add permission_set_name badge with color coding:
- own_data → gray
- read_only → blue
- normal_user → green
- admin → red
- 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:
-
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)
-
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
-
Property LiveViews:
- Similar to Member (hide create/edit/delete based on permissions)
-
PropertyType LiveViews:
- All users can view
- Only admin can create/edit/delete
-
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
-
Test all views with all 5 role types
Acceptance Criteria:
- All LiveViews use
can?/3for 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.exstest/mv_web/live/user_live_authorization_test.exstest/mv_web/live/property_live_authorization_test.exstest/mv_web/live/property_type_live_authorization_test.exstest/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:
-
Create test file for each role:
test/integration/mitglied_journey_test.exstest/integration/vorstand_journey_test.exstest/integration/kassenwart_journey_test.exstest/integration/buchhaltung_journey_test.exstest/integration/admin_journey_test.exs
-
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
-
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:
- Login as Mitglied user
- Can access home page and profile
- Cannot access /members (redirected)
- Cannot access /admin/roles (redirected)
- Can view own linked member via direct URL
- Can update own member data
- Cannot update unlinked member
- Can update own user credentials
- Cannot view other users
Vorstand Journey:
- Login as Vorstand user
- Can access /members (reads all members)
- Cannot create member (no button in UI, backend forbids)
- Cannot edit member (no button in UI, backend forbids)
- Can access /members/:id (read-only view)
- Cannot access /members/:id/edit (page permission denies)
- Can update own credentials
- Cannot access /admin/roles
Kassenwart Journey:
- Login as Kassenwart user
- Can access /members
- Can create new member
- Can edit any member (except email if linked - see special case)
- Cannot delete member
- Can manage properties
- Cannot manage property types (read-only)
- Cannot access /admin/roles
Buchhaltung Journey:
- Login as Buchhaltung user
- Can access /members (read-only)
- Cannot create/edit members
- Can view properties (read-only)
- Same restrictions as Vorstand
Admin Journey:
- Login as Admin user
- Can access all pages (wildcard permission)
- Can CRUD all resources
- Can edit member email even if linked
- Can manage roles
- Cannot delete system roles (backend prevents)
- Can link/unlink users and members
- 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.exstest/integration/vorstand_journey_test.exstest/integration/kassenwart_journey_test.exstest/integration/buchhaltung_journey_test.exstest/integration/admin_journey_test.exstest/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:
- Read acceptance criteria
- Write failing tests covering all criteria
- Verify tests fail (red)
- Implement minimum code to pass
- Verify tests pass (green)
- Refactor if needed
- 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:
# 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:
mix run priv/repo/seeds/authorization_seeds.exs
Rollback Plan
If issues discovered in production:
-
Immediate Rollback:
- Set
ENABLE_RBAC=falseenvironment variable - Restart application
- Old authorization system takes over instantly
- Set
-
Database Rollback (if needed):
mix ecto.rollback --step 1- Removes
role_idfrom users - Removes
rolestable - Existing auth untouched
- Removes
-
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
:fieldskey - Implement Ash Calculations to filter readable fields
- Implement Custom Validations for writable fields
- No database changes needed
- See Architecture Document for details
Phase 3: Database-Backed Permissions (Future - 3-4 weeks)
- Create
permission_sets,permission_set_resources,permission_set_pagestables - Replace hardcoded PermissionSets module with DB queries
- Implement ETS cache for performance
- Allow runtime permission configuration
- See Architecture Document 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 logiclib/mv/authorization/checks/has_permission.ex- Ash policy checklib/mv_web/authorization.ex- UI helper functionslib/mv_web/plugs/check_page_permission.ex- Page access controlpriv/repo/seeds/authorization_seeds.exs- Role seed data
Useful Commands
# 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