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
- 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. 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
- 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, 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
- Own Credentials: Users can always edit their own email and password
- Linked Member Email: Only administrators or the linked user themselves can change the email of a member linked to a user
- 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
- 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: 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
:userargument drives the relationship viamanage_relationship. - On update,
on_missing: :ignoremeans omitting:userleaves the link unchanged (no "unlink by omission"); unlink is explicit (user: nil). - The policy check
ForbidMemberUserLinkUnlessAdminforbids the action for non-admins whenever the:userargument 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 |
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: Historical record of how the MVP was built (PR #346/#345)
CODE_GUIDELINES.md: Project coding standards