mitgliederverwaltung/docs/roles-and-permissions-overview.md

15 KiB

Roles and Permissions - Architecture Overview

Project: Mila - Membership Management System
Feature: Role-Based Access Control (RBAC) with Hardcoded Permission Sets
Version: 2.0
Last Updated: 2026-01-13
Status: Implemented (2026-01-08, PR #346, closes #345)


Purpose of This Document

This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.

For detailed technical implementation: See roles-and-permissions-architecture.md


Table of Contents

  1. Overview
  2. Requirements Summary
  3. Evaluated Approaches
  4. Selected Architecture
  5. Permission System Design
  6. User-Member Linking Strategy
  7. Field-Level Permissions Strategy
  8. Migration Strategy
  9. Related Documents

Overview

The Mila membership management system requires a flexible authorization system that controls:

  • Who can access what resources
  • Which pages users can view
  • How users interact with their own vs. others' data

Key Design Principles

  1. Simplicity First: Start with hardcoded permissions for fast MVP delivery
  2. Performance: No database queries for permission checks in MVP
  3. Clear Migration Path: Easy upgrade to database-backed permissions when needed
  4. Security: Explicit action-based authorization with no ambiguity
  5. Maintainability: Permission logic reviewable in Git, testable as pure functions

Core Concepts

Permission Set: Defines a collection of permissions (e.g., "read_only", "admin")

Role: A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")

User: Each user has exactly one Role, inheriting that Role's Permission Set

Scope: Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)


Evaluated Approaches

During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.

Approach 1: JSONB in Roles Table

Store all permissions as a single JSONB column directly in the roles table. Simplest schema (single table), flexible, fast to implement — but poor queryability (can't filter by specific permissions), no referential integrity, hard to validate/audit, can't use indexes.

Verdict: Rejected - Poor queryability makes it unsuitable for complex permission logic.


Approach 2: Normalized Database Tables

Separate tables for permission_sets, permission_set_resources, permission_set_pages with full normalization. Fully queryable, runtime-configurable, strong referential integrity, auditable, indexable — but complex schema (4+ tables), a DB query per check, needs ETS cache + admin UI, 4-5 weeks, overkill for 4 fixed sets.

Verdict: Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.


Approach 3: Custom Authorizer

Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Full control over logic — but significantly more code, loses Ash's declarative policies (must reimplement query filter generation), harder to test, mixes declarative/imperative, higher bug risk.

Verdict: Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.


Approach 4: Simple Role Enum

Add a :role enum field directly on User with hardcoded checks in each policy. Very simple (< 1 week), no extra tables, fast — but no separation of role (job function) from permission set, can't add roles without code changes, no dynamic config, not extensible to field-level, hard to maintain as requirements grow.

Verdict: Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.


Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)

Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (2-3 weeks vs 4-5), maximum performance (zero DB queries, < 1μs), pure-function testing, Git-reviewable permissions, no data migration, keeps role/permission-set separation, clear Phase 3 upgrade path. Trade-offs: permissions not editable at runtime (only role assignment), new permissions need a code deploy, unsuitable if permissions change > 1x/week, limited to the 4 predefined sets.

Why Selected: MVP requires 4 fixed sets (not custom ones), no stated need for runtime permission editing, performance is critical, fast time-to-market, and a clear upgrade path exists when runtime config becomes necessary.

Migration Path: When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.


Requirements Summary

Four Predefined Permission Sets

  1. own_data - Access only to own user account and linked member profile
  2. read_only - Read access to all members and custom fields
  3. normal_user - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
  4. admin - Unrestricted access to all resources including user management

Example Roles

  • Mitglied (Member) - Uses "own_data" permission set, default role
  • Vorstand (Board) - Uses "read_only" permission set
  • Kassenwart (Treasurer) - Uses "normal_user" permission set
  • Buchhaltung (Accounting) - Uses "read_only" permission set
  • Admin - Uses "admin" permission set

Authorization Levels

Resource Level (MVP):

  • Controls create, read, update, destroy actions on resources
  • Resources: Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle, JoinRequest

Page Level (MVP):

  • Controls access to LiveView pages
  • Example: "/members/new" requires Member.create permission

Field Level (Phase 2 - Future):

  • Controls read/write access to specific fields
  • Example: Only Treasurer can see payment_history field

Special Cases

  1. Own Credentials: Users can always edit their own email and password
  2. Linked Member Email: Only administrators or the linked user themselves can change the email of a member linked to a user
  3. User-Member Linking: Only admins can link/unlink users to members (except self-service creation)

Selected Architecture

Conceptual Model

Elixir Module: PermissionSets
    ↓ (defines)
Permission Set (:own_data, :read_only, :normal_user, :admin)
    ↓ (referenced by)
Role (stored in DB: "Vorstand" → "read_only")
    ↓ (assigned to)
User (each user has one role_id)

Database Schema (MVP)

Single Table: roles

Contains:

  • id (UUID)
  • name (e.g., "Vorstand")
  • description
  • permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
  • is_system_role (boolean, protects critical roles)

No Permission Tables: Permission Sets are hardcoded in Elixir module.

Why This Approach?

Fast Implementation: 2-3 weeks instead of 4-5 weeks

Maximum Performance:

  • Zero database queries for permission checks
  • Pure function calls (< 1 microsecond)
  • No caching needed

Code Review:

  • Permissions visible in Git diffs
  • Easy to review changes
  • No accidental runtime modifications

Clear Upgrade Path:

  • Phase 1 (MVP): Hardcoded
  • Phase 2: Add field-level permissions
  • Phase 3: Migrate to database-backed with admin UI

Meets Requirements:

  • Four predefined permission sets ✓
  • Dynamic role creation ✓ (Roles in DB)
  • Role-to-user assignment ✓
  • No requirement for runtime permission changes stated

Permission System Design

Permission Structure

Each Permission Set contains:

Resources: List of resource permissions

  • resource: "Member", "User", "CustomFieldValue", etc.
  • action: :read, :create, :update, :destroy
  • scope: :own, :linked, :all
  • granted: true/false

Pages: List of accessible page paths

  • Examples: "/", "/members", "/members/:id/edit"
  • "*" for admin (all pages)

Scope Definitions

:own - Only records where id == actor.id

  • Example: User can read their own User record

:linked - Only records linked to actor via relationships

  • Member: id == actor.member_id (User.member_id → Member.id, inverse relationship)
  • CustomFieldValue: member_id == actor.member_id (traverses Member → User relationship)
  • Example: User can read Member linked to their account

:all - All records without restriction

  • Example: Admin can read all Members

How Authorization Works

  1. User attempts action on resource (e.g., read Member)
  2. System loads user's role from database
  3. Role contains permission_set_name string
  4. PermissionSets module returns permissions for that set
  5. Custom Policy Check evaluates permissions against action
  6. Access granted or denied based on scope

Custom Policy Check

A reusable Ash Policy Check that:

  • Reads user's permission_set_name from their role
  • Calls PermissionSets.get_permissions/1
  • Matches resource + action against permissions list
  • Applies scope filters (own/linked/all)
  • Returns authorized, forbidden, or filtered query

User-Member Linking Strategy

Problem Statement

Users need to create member profiles for themselves (self-service), but only admins should be able to:

  • Link existing members to users
  • Unlink members from users
  • Create members pre-linked to arbitrary users

Selected Approach: Admin-Only :user Argument

Linking is not modelled as separate per-operation actions. The Member resource has a single create_member and a single update_member action; linking and unlinking happen through an optional :user argument on those actions. user_id is deliberately not accepted, so the foreign key cannot be set directly.

How Linking Works on the Member Resource

create_member / update_member (the only Member write actions)

  • The optional :user argument drives the relationship via manage_relationship.
  • On update, on_missing: :ignore means omitting :user leaves the link unchanged (no "unlink by omission"); unlink is explicit (user: nil).
  • The policy check ForbidMemberUserLinkUnlessAdmin forbids the action for non-admins whenever the :user argument is present (any value), so only admins may set or change the link.
  • Non-admins can still create/update members as long as they do not pass :user.

Self-service ("a user creates a member linked to themselves") is handled on the User side: the admin-only update_user action takes a :member argument for link/unlink, and the UI exposes the linking controls only to admins.

Why This Design?

Single write path: one create and one update action to reason about, instead of a fan-out of link_*/unlink_* actions.

Centralized rule: the admin-only constraint lives in one reusable policy check (ForbidMemberUserLinkUnlessAdmin).

Server-Side Security: user_id is never accepted directly, so it cannot be mass-assigned — only argument-driven relationship management can change it.

Better UX: distinct UI flows for self-service vs. admin linking.


Field-Level Permissions Strategy

Status: Phase 2 (Future Implementation)

Field-level permissions are NOT implemented in MVP but have a clear strategy defined.

Problem Statement

Some scenarios require field-level control:

  • Read restrictions: Hide payment_history from certain roles
  • Write restrictions: Only treasurer can edit payment fields
  • Complexity: Ash Policies work at resource level, not field level

Selected Strategy

For Read Restrictions: Use Ash Calculations or Custom Preparations

  • Calculations: Dynamically compute field based on permissions
  • Preparations: Filter select to only allowed fields
  • Field returns nil or "[Hidden]" if unauthorized

For Write Restrictions: Use Custom Validations

  • Validate changeset against field permissions
  • Similar to existing linked-member email validation
  • Return error if field modification not allowed

Why This Strategy?

Leverages Ash Features: Uses built-in mechanisms, not custom authorizer

Performance: Calculations are lazy, Preparations run once per query

Maintainable: Clear validation logic, standard Ash patterns

Extensible: Easy to add new field restrictions

Implementation Timeline

Phase 1 (MVP): No field-level permissions

Phase 2: Extend PermissionSets to include field permissions, implement Calculations/Validations

Phase 3: If migrating to database, add permission_set_fields table


Migration Strategy

Phase 1: MVP with Hardcoded Permissions (2-3 weeks)

What's Included:

  • Roles table in database
  • PermissionSets Elixir module with 4 predefined sets
  • Custom Policy Check reading from module
  • UI Authorization Helpers for LiveView
  • Admin UI for role management (create, assign, delete roles)

Limitations:

  • Permissions not editable at runtime
  • New permissions require code deployment
  • Only 4 permission sets available

Benefits:

  • Fast implementation
  • Maximum performance
  • Simple testing and review

Phase 2: Field-Level Permissions (Future, 2-3 weeks)

When Needed: Business requires field-level restrictions

Implementation:

  • Extend PermissionSets module with :fields key
  • Add Ash Calculations for read restrictions
  • Add custom validations for write restrictions
  • Update UI Helpers

Migration: No database changes, pure code additions

Phase 3: Database-Backed Permissions (Future, 3-4 weeks)

When Needed: Runtime permission configuration required

Implementation:

  • Create permission tables in database
  • Seed script to migrate hardcoded permissions
  • Update PermissionSets module to query database
  • Add ETS cache for performance
  • Build admin UI for permission management

Migration: Seamless, no changes to existing Policies or UI code

Decision Matrix: When to Migrate?

Scenario Recommended Phase
MVP with 4 fixed permission sets Phase 1
Need field-level restrictions Phase 2
Permission changes < 1x/month Stay Phase 1
Need runtime permission config Phase 3
Custom permission sets needed Phase 3
Permission changes > 1x/week Phase 3

This Document (Overview): High-level concepts, no code examples

roles-and-permissions-architecture.md: Complete technical specification with code examples

roles-and-permissions-implementation-plan.md: Historical record of how the MVP was built (PR #346/#345)

CODE_GUIDELINES.md: Project coding standards