mitgliederverwaltung/docs/roles-and-permissions-overview.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

16 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: 2025-11-13
Status: Architecture Design - MVP Approach


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.

Advantages:

  • Simplest database schema (single table)
  • Very flexible structure
  • No additional tables needed
  • Fast to implement

Disadvantages:

  • Poor queryability (can't efficiently filter by specific permissions)
  • No referential integrity
  • Difficult to validate structure
  • Hard to audit permission changes
  • Can't leverage database indexes effectively

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.

Advantages:

  • Fully queryable with SQL
  • Runtime configurable permissions
  • Strong referential integrity
  • Easy to audit changes
  • Can index for performance

Disadvantages:

  • Complex database schema (4+ tables)
  • DB queries required for every permission check
  • Requires ETS cache for performance
  • Needs admin UI for permission management
  • Longer implementation time (4-5 weeks)
  • Overkill for fixed set of 4 permission 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.

Advantages:

  • Complete control over authorization logic
  • Can implement any custom behavior
  • Not constrained by Ash Policy DSL

Disadvantages:

  • Significantly more code to write and maintain
  • Loses benefits of Ash's declarative policies
  • Harder to test than built-in policy system
  • Mixes declarative and imperative approaches
  • Must reimplement filter generation for queries
  • Higher bug risk

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


Approach 4: Simple Role Enum

Add a simple :role enum field directly on User resource with hardcoded checks in each policy.

Advantages:

  • Very simple to implement (< 1 week)
  • No extra tables needed
  • Fast performance
  • Easy to understand

Disadvantages:

  • No separation between roles and permissions
  • Can't add new roles without code changes
  • No dynamic permission configuration
  • Not extensible to field-level permissions
  • Violates separation of concerns (role = job function, not permission set)
  • Difficult 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.

Advantages:

  • Fast implementation (2-3 weeks vs 4-5 weeks)
  • Maximum performance (zero DB queries, < 1 microsecond)
  • Simple to test (pure functions)
  • Code-reviewable permissions (visible in Git)
  • No migration needed for existing data
  • Clearly defined 4 permission sets as required
  • Clear migration path to database-backed solution (Phase 3)
  • Maintains separation of roles and permission sets

Disadvantages:

  • Permissions not editable at runtime (only role assignment possible)
  • New permissions require code deployment
  • Not suitable if permissions change frequently (> 1x/week)
  • Limited to the 4 predefined permission sets

Why Selected:

  • MVP requirement is for 4 fixed permission sets (not custom ones)
  • No stated requirement for runtime permission editing
  • Performance is critical for authorization checks
  • Fast time-to-market (2-3 weeks)
  • Clear upgrade path when runtime configuration 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, Property, PropertyType, Role

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 admins can edit email of members linked to users
  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", "Property", 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 where user_id == actor.id

  • 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: Separate Ash Actions

Instead of complex field-level validation, we use action-based authorization.

Actions on Member Resource

1. create_member_for_self (All authenticated users)

  • Automatically sets user_id = actor.id
  • User cannot specify different user_id
  • UI: "Create My Profile" button

2. create_member (Admin only)

  • Can set user_id to any user or leave unlinked
  • Full flexibility for admin
  • UI: Admin member management form

3. link_member_to_user (Admin only)

  • Updates existing member to set user_id
  • Connects unlinked member to user account

4. unlink_member_from_user (Admin only)

  • Sets user_id to nil
  • Disconnects member from user account

5. update (Permission-based)

  • Normal updates (name, address, etc.)
  • user_id NOT in accept list (prevents manipulation)
  • Available to users with Member.update permission

Why Separate Actions?

Explicit Semantics: Each action has clear, single purpose

Server-Side Security: user_id set by server, not client input

Better UX: Different UI flows for different use cases

Simple Policies: Authorization at action level, not field level

Easy Testing: Each action independently testable


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: Detailed implementation plan with TDD approach

CODE_GUIDELINES.md: Project coding standards


Summary

The selected architecture uses hardcoded Permission Sets in Elixir for the MVP, providing:

  • Speed: 2-3 weeks implementation vs 4-5 weeks
  • Performance: Zero database queries for authorization
  • Clarity: Permissions in Git, reviewable and testable
  • Flexibility: Clear migration path to database-backed system

User-Member linking uses separate Ash Actions for clarity and security.

Field-level permissions have a defined strategy (Calculations + Validations) for Phase 2 implementation.

The approach balances pragmatism for MVP delivery with extensibility for future requirements.