mitgliederverwaltung/docs/roles-and-permissions-implementation-plan.md
Moritz a19026e430
All checks were successful
continuous-integration/drone/push Build is passing
docs: update roles and permissions architecture and implementation plan
2025-11-13 16:17:01 +01:00

57 KiB

Roles and Permissions - Implementation Plan (MVP)

Version: 2.0 (Clean Rewrite)
Date: 2025-01-13
Status: Ready for Implementation
Related Documents:


Table of Contents


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:
    @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)
    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)
    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
    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:
    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:
    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:
    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:
    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:
    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:
    <%= 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:

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

  1. Immediate Rollback:

    • Set ENABLE_RBAC=false environment variable
    • Restart application
    • Old authorization system takes over instantly
  2. Database Rollback (if needed):

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

# 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