docs: update roles and permissions architecture and implementation plan
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Moritz 2025-11-11 15:57:11 +01:00
parent 1084f67f1f
commit a19026e430
Signed by: moritz
GPG key ID: 1020A035E5DD0824
3 changed files with 4159 additions and 4145 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,506 @@
# 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](./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.
**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 |
---
## 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):** Detailed implementation plan with TDD approach
**[CODE_GUIDELINES.md](../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.