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
- Overview
- Requirements Summary
- Evaluated Approaches
- Selected Architecture
- Permission System Design
- User-Member Linking Strategy
- Field-Level Permissions Strategy
- Migration Strategy
- 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
- Simplicity First: Start with hardcoded permissions for fast MVP delivery
- Performance: No database queries for permission checks in MVP
- Clear Migration Path: Easy upgrade to database-backed permissions when needed
- Security: Explicit action-based authorization with no ambiguity
- 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
- own_data - Access only to own user account and linked member profile
- read_only - Read access to all members and custom fields
- normal_user - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
- 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
- Own Credentials: Users can always edit their own email and password
- Linked Member Email: Only admins can edit email of members linked to users
- 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
- User attempts action on resource (e.g., read Member)
- System loads user's role from database
- Role contains permission_set_name string
- PermissionSets module returns permissions for that set
- Custom Policy Check evaluates permissions against action
- 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 |
Related Documents
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.