408 lines
15 KiB
Markdown
408 lines
15 KiB
Markdown
# 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](./roles-and-permissions-architecture.md)
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Overview](#overview)
|
|
2. [Requirements Summary](#requirements-summary)
|
|
3. [Evaluated Approaches](#evaluated-approaches)
|
|
4. [Selected Architecture](#selected-architecture)
|
|
5. [Permission System Design](#permission-system-design)
|
|
6. [User-Member Linking Strategy](#user-member-linking-strategy)
|
|
7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
|
|
8. [Migration Strategy](#migration-strategy)
|
|
9. [Related Documents](#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 |
|
|
|
|
---
|
|
|
|
## Related Documents
|
|
|
|
**This Document (Overview):** High-level concepts, no code examples
|
|
|
|
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
|
|
|
|
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Historical record of how the MVP was built (PR #346/#345)
|
|
|
|
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
|
|
|