# 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.

**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, CustomFieldValue, CustomField, 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", "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: 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.

