From a19026e430aee354a5abd14d0eaf177f9fbf95d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 11 Nov 2025 15:57:11 +0100 Subject: [PATCH] docs: update roles and permissions architecture and implementation plan --- docs/roles-and-permissions-architecture.md | 3969 +++++++++-------- ...les-and-permissions-implementation-plan.md | 3829 +++++++--------- docs/roles-and-permissions-overview.md | 506 +++ 3 files changed, 4159 insertions(+), 4145 deletions(-) create mode 100644 docs/roles-and-permissions-overview.md diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index f9de090..fa45d86 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1,46 +1,67 @@ -# Roles and Permissions Architecture +# Roles and Permissions Architecture - Technical Specification -**Project:** Mila - Membership Management System -**Feature:** Role-Based Access Control (RBAC) with Permission Sets -**Version:** 1.0 -**Last Updated:** 2025-11-10 -**Status:** Architecture Design +**Version:** 2.0 (Clean Rewrite) +**Date:** 2025-01-13 +**Status:** Ready for Implementation +**Related Documents:** +- [Overview](./roles-and-permissions-overview.md) - High-level concepts for stakeholders +- [Implementation Plan](./roles-and-permissions-implementation-plan.md) - Step-by-step implementation guide --- ## Table of Contents -1. [Overview](#overview) -2. [Requirements Analysis](#requirements-analysis) -3. [Evaluated Approaches](#evaluated-approaches) -4. [Selected Architecture](#selected-architecture) -5. [Database Schema](#database-schema) -6. [Permission System Design](#permission-system-design) -7. [Implementation Details](#implementation-details) -8. [Future Extensions](#future-extensions) -9. [Migration Strategy](#migration-strategy) -10. [Security Considerations](#security-considerations) +- [Overview](#overview) +- [Requirements Analysis](#requirements-analysis) +- [Selected Architecture](#selected-architecture) +- [Database Schema (MVP)](#database-schema-mvp) +- [Permission System Design (MVP)](#permission-system-design-mvp) +- [Resource Policies](#resource-policies) +- [Page Permission System](#page-permission-system) +- [UI-Level Authorization](#ui-level-authorization) +- [Special Cases](#special-cases) +- [User-Member Linking](#user-member-linking) +- [Future: Phase 2 - Field-Level Permissions](#future-phase-2---field-level-permissions) +- [Future: Phase 3 - Database-Backed Permissions](#future-phase-3---database-backed-permissions) +- [Migration Strategy](#migration-strategy) +- [Security Considerations](#security-considerations) +- [Appendix](#appendix) --- ## Overview -This document describes the architecture for implementing a flexible, scalable role-based access control (RBAC) system for the Mila membership management application. The system provides: - -- **Predefined Permission Sets** with configurable permissions -- **Dynamic Roles** that reference permission sets -- **Resource-level and Action-level** authorization -- **Page-level** access control for LiveView routes -- **Special handling** for credentials and linked user-member relationships -- **Future extensibility** for field-level permissions +This document provides the complete technical specification for the **Roles and Permissions system** in the Mila membership management application. The system controls who can access what data and which actions they can perform. ### Key Design Principles -1. **Separation of Concerns:** Permission Sets (what you can do) vs. Roles (job titles/functions) -2. **Flexibility:** Admins can configure permissions at runtime via database -3. **Performance:** Leverage Ash Framework policies with ETS caching -4. **Extensibility:** Architecture supports future field-level granularity -5. **Consistency:** Single unified permission model for all resources +1. **Security First:** Authorization is enforced at multiple layers (database policies, page access, UI rendering) +2. **Performance:** MVP uses hardcoded permissions for < 1 microsecond checks +3. **Maintainability:** Clear separation between roles (data) and permissions (logic) +4. **Extensibility:** Clean migration path to database-backed permissions (Phase 3) +5. **User Experience:** Consistent authorization across backend and frontend +6. **Test-Driven:** All components fully tested with behavior-focused tests + +### Architecture Approach + +**MVP (Phase 1) - Hardcoded Permission Sets:** +- Permission logic in Elixir module (`Mv.Authorization.PermissionSets`) +- Role data in database (`roles` table) +- Roles reference permission sets by name (string) +- Zero database queries for permission checks +- Implementation time: 2-3 weeks + +**Future (Phase 2) - Field-Level Permissions:** +- Extend PermissionSets with field-level granularity +- Ash Calculations for read filtering +- Custom Validations for write protection +- No database schema changes + +**Future (Phase 3) - Database-Backed Permissions:** +- Move permission data to database tables +- Runtime permission configuration +- ETS cache for performance +- Migration from hardcoded module --- @@ -48,1011 +69,1101 @@ This document describes the architecture for implementing a flexible, scalable r ### Core Requirements -Based on the project requirements, the system must support: +**1. Predefined Permission Sets** -#### Permission Sets (4 Predefined) +Four hardcoded permission sets that define access patterns: -1. **Own Data** - Users can only access their own data -2. **Read-Only** - Read access to all members, groups, and custom fields -3. **Normal User** - Read and write access to members and custom fields -4. **Admin** - Full access to all resources including user management +- **own_data** - User can only access their own data (default for members) +- **read_only** - Read access to all member data, no modifications +- **normal_user** - Create/Read/Update on members (no delete), full CRUD on custom fields +- **admin** - Unrestricted access including user/role management -#### Example Roles +**2. Roles Stored in Database** -- **Mitglied** (Member) - Default role, own data access -- **Vorstand** (Board) - Access to members, not users -- **Kassenwart** (Treasurer) - Access to payment information -- **Buchhaltung** (Accounting) - Read-only access -- **Admin** - Full administrative access +Five predefined roles stored in the `roles` table: -#### Authorization Granularity +- **Mitglied** (Member) → uses "own_data" permission set +- **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 -**Resource Level (Phase 1 - Now):** -- Member: read, create, update, destroy -- User: read, create, update, destroy -- PropertyType: read, create, update, destroy -- Property: read, create, update, destroy -- Role: read, create, update, destroy -- Payment (future): read, create, update, destroy +**3. Resource-Level Permissions** -**Page Level:** -- Control access to LiveView pages -- Pages are read-only access checks -- Edit pages require both page access AND resource write permission +Control CRUD operations on: +- User (credentials, profile) +- Member (member data) +- Property (custom field values) +- PropertyType (custom field definitions) +- Role (role management) -**Field Level (Phase 2 - Later):** -- Restrict read/write access to specific member fields -- Restrict access to specific custom field types -- Example: Treasurer sees payment_history, Board does not +**4. Page-Level Permissions** -#### Special Cases +Control access to LiveView pages: +- Index pages (list views) +- Show pages (detail views) +- Form pages (create/edit) +- Admin pages -1. **User Credentials:** - - Users can ALWAYS edit their own credentials (email, password) - - Only Admins can edit OTHER users' credentials - - Email field of members linked to users can only be edited by Admins +**5. Granular Scopes** -2. **Required Fields:** - - When creating a member with required custom fields, user must be able to write those fields - - Even if user normally doesn't have write permission for that field type - - After creation, normal permissions apply +Three scope levels for permissions: +- **:own** - Only records where `record.id == user.id` (for User resource) +- **:linked** - Only records linked to user via relationships + - Member: `member.user_id == user.id` + - Property: `property.member.user_id == user.id` +- **:all** - All records, no filtering -3. **Payment History (Future):** - - Configurable per permission set - - Members may or may not see their own payment history +**6. Special Cases** -4. **Linked User-Member Relationships:** - - Member email sync follows special rules - - User email is source of truth for linked members +- **Own Credentials:** Every user can always read/update their own credentials +- **Linked Member Email:** Only admins can edit email of member linked to user +- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag) +- **User-Member Linking:** Only admins can link/unlink users and members -#### Constraints +**7. UI Consistency** -- Roles can be added, renamed, or removed by admins -- Permission sets are predefined but permissions are configurable -- Each user has exactly ONE role -- Roles cannot overlap (no multiple role assignment per user) -- "Mitglied" role is a system role and cannot be deleted -- Permission sets are system-defined and cannot be deleted - ---- - -## Evaluated Approaches - -We evaluated four different architectural approaches for implementing the authorization system: - -### Approach 1: Ash Policies + RBAC with JSONB Permissions - -**Description:** Store permissions as JSONB in the Role resource, use custom Ash Policy checks to evaluate them. - -**Database Structure:** -``` -roles (id, name, permissions_config: jsonb) -users (role_id) -``` - -**Permissions stored as:** -```json -{ - "resource_permissions": { - "Member": {"read": true, "update": false} - }, - "page_permissions": { - "/members": true - } -} -``` - -**Advantages:** -- ✅ Simple database schema (fewer tables) -- ✅ Flexible JSON structure -- ✅ Fast schema changes (no migrations needed) -- ✅ Easy to serialize/deserialize - -**Disadvantages:** -- ❌ No referential integrity on permission keys -- ❌ JSONB queries are less efficient than normalized tables -- ❌ Difficult to query "which roles have access to X?" -- ❌ Schema validation happens in application code -- ❌ No indexing on individual permissions -- ❌ Versioning of JSONB structure becomes complex - -**Verdict:** ❌ Not selected - JSONB makes querying and validation difficult - ---- - -### Approach 2: Hybrid RBAC + ABAC with Permission Matrix - -**Description:** Separate tables for every permission type with full granularity from day one. - -**Database Structure:** -``` -roles (id, name) -permissions (id, resource, action, field, condition) -role_permissions (role_id, permission_id, granted) -user_roles (user_id, role_id) -``` - -**Advantages:** -- ✅ Maximum flexibility -- ✅ Highly granular from the start -- ✅ Easy to add new permission types -- ✅ Audit trail built-in - -**Disadvantages:** -- ❌ Very complex database schema -- ❌ High JOIN overhead on every authorization check -- ❌ Over-engineered for current requirements -- ❌ Difficult to cache effectively -- ❌ Performance concerns with many permissions -- ❌ Complex to seed and maintain - -**Verdict:** ❌ Not selected - Too complex for current needs, over-engineering - ---- - -### Approach 3: Policy Graphs with Custom Authorizer - -**Description:** Use Ash Policies for action-level checks, custom Authorizer module for field-level filtering. - -**Database Structure:** -``` -roles (id, name, permission_config) -Custom Authorizer reads config and applies filters -``` - -**Advantages:** -- ✅ Best performance (optimized for Ash) -- ✅ Granular field-level control -- ✅ Can leverage Ash query optimization - -**Disadvantages:** -- ❌ Requires custom authorizer implementation (non-standard) -- ❌ More code to maintain -- ❌ Harder to test than declarative policies -- ❌ Mixes declarative (Policies) and imperative (Authorizer) approaches - -**Verdict:** ❌ Not selected - Too much custom code, reduces maintainability - ---- - -### Approach 4: Simple Role Enum (Quick Start) - -**Description:** Simple `:role` field on User with enum values, policies hardcoded in resources. - -**Database Structure:** -``` -users (role: :admin | :vorstand | :kassenwart | :member) -``` - -**Advantages:** -- ✅ Very simple to implement (1 week) -- ✅ No extra tables needed -- ✅ Fast performance -- ✅ Easy to understand - -**Disadvantages:** -- ❌ No dynamic permission configuration -- ❌ Requires code deployment to change permissions -- ❌ Can't add new roles without code changes -- ❌ Not extensible to field-level permissions -- ❌ Doesn't meet requirement for "configurable permissions" - -**Verdict:** ❌ Not selected - Doesn't meet core requirements +- UI elements (buttons, links) only shown if user has permission +- Page access controlled before LiveView mounts +- Consistent authorization logic between backend and frontend --- ## Selected Architecture -### Approach 5: Permission Sets + Normalized Tables (Selected) +### Approach: Hardcoded Permission Sets with Database Roles -**Description:** Hybrid approach that separates Permission Sets (what you can do) from Roles (who you are), with normalized database tables for queryability and Ash Policies for enforcement. - -**Key Innovation:** Introduce **Permission Sets** as an abstraction layer between Roles and actual Permissions. +**Core Concept:** ``` -Permission Set (4 predefined, defines capabilities) - ↓ -Role (many, references one Permission Set) - ↓ -User (each has one Role) +PermissionSets Module (hardcoded in code) + ↓ (referenced by permission_set_name) +Role (stored in DB: "Vorstand" → "read_only") + ↓ (assigned to user via role_id) +User (each user has one role) ``` **Why This Approach?** -1. **Meets Requirements:** - - ✅ Configurable permissions (stored in database) - - ✅ Dynamic role creation - - ✅ Extensible to field-level - - ✅ Admin UI can modify at runtime +✅ **Fast Implementation:** 2-3 weeks vs. 4-5 weeks for DB-backed +✅ **Maximum Performance:** < 1 microsecond per check (pure function call) +✅ **Zero DB Overhead:** No permission queries, no joins, no cache needed +✅ **Git-Tracked Changes:** All permission changes in version control +✅ **Deterministic Testing:** No DB setup, purely functional tests +✅ **Clear Migration Path:** Well-defined Phase 3 for DB-backed permissions -2. **Performance:** - - ✅ Normalized tables allow efficient queries - - ✅ Indexes on resource_name and action - - ✅ ETS cache for permission lookups - - ✅ Ash Policies translate to SQL filters +**Trade-offs:** -3. **Maintainability:** - - ✅ Clear separation of concerns - - ✅ Standard Ash patterns (not custom authorizer) - - ✅ Testable with standard Ash policy tests - - ✅ Easy to understand and debug +⚠️ **Deployment Required:** Permission changes need code deployment +⚠️ **Four Fixed Sets:** Cannot add new permission sets without code change +✔️ **Acceptable for MVP:** Requirements specify 4 fixed sets, rare changes expected -4. **Extensibility:** - - ✅ `field_name` column reserved for Phase 2 - - ✅ `scope` system handles "own" vs "all" vs "linked" - - ✅ New resources just add permission rows - - ✅ No code changes needed for new roles +### System Architecture Diagram -5. **Flexibility:** - - ✅ Permission Sets ensure consistency - - ✅ Roles can be renamed without changing permissions - - ✅ Multiple roles can share same permission set - - ✅ Admin can configure at runtime +``` +┌─────────────────────────────────────────────────────────────┐ +│ Authorization System │ +└─────────────────────────────────────────────────────────────┘ -**Trade-offs Accepted:** -- More tables than JSONB approach (but better queryability) -- More rows than enum approach (but runtime configurable) -- Not as granular as full ABAC (but simpler to manage) +┌──────────────────┐ +│ LiveView │ +│ (UI Layer) │ +└────────┬─────────┘ + │ + │ 1. Page Access Check + ↓ +┌──────────────────────────────────┐ +│ CheckPagePermission Plug │ +│ - Reads PermissionSets module │ +│ - Matches page pattern │ +│ - Redirects if unauthorized │ +└────────┬─────────────────────────┘ + │ + │ 2. UI Element Check + ↓ +┌──────────────────────────────────┐ +│ MvWeb.Authorization │ +│ - can?/3 │ +│ - can_access_page?/2 │ +│ - Uses PermissionSets module │ +└────────┬─────────────────────────┘ + │ + │ 3. Resource Action + ↓ +┌──────────────────────────────────┐ +│ Ash Resource (Member, User...) │ +│ - Policies block │ +└────────┬─────────────────────────┘ + │ + │ 4. Policy Evaluation + ↓ +┌──────────────────────────────────┐ +│ HasPermission Policy Check │ +│ - Reads actor.role │ +│ - Calls PermissionSets.get_permissions/1 │ +│ - Applies scope filter │ +└────────┬─────────────────────────┘ + │ + │ 5. Permission Lookup + ↓ +┌──────────────────────────────────┐ +│ PermissionSets Module │ +│ (Hardcoded in code) │ +│ - get_permissions/1 │ +│ - Returns {resources, pages} │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ Database │ +│ - roles table │ +│ - users.role_id → roles.id │ +└──────────────────────────────────┘ +``` + +**Authorization Flow:** + +1. **Page Request:** Plug checks if user can access page +2. **UI Rendering:** Helper checks which buttons/links to show +3. **User Action:** Ash receives action request (create, read, update, destroy) +4. **Policy Check:** `HasPermission` evaluates permission +5. **Permission Lookup:** Reads from `PermissionSets` module (in-memory) +6. **Scope Application:** Filters query based on scope (:own, :linked, :all) +7. **Result:** Action succeeds or fails with Forbidden error --- -## Database Schema +## Database Schema (MVP) + +### Overview + +The MVP requires **only ONE new table**: `roles` + +- ✅ Stores role definitions (name, description, permission_set_name) +- ✅ Links to users via foreign key +- ❌ NO permission tables (permissions are hardcoded) ### Entity Relationship Diagram ``` -┌─────────────────────┐ -│ permission_sets │ -│─────────────────────│ -│ id (PK) │ -│ name │◄───────┐ -│ description │ │ -│ is_system │ │ -└─────────────────────┘ │ - │ - │ -┌─────────────────────────────┐│ -│ permission_set_resources ││ -│─────────────────────────────││ -│ id (PK) ││ -│ permission_set_id (FK) │┘ -│ resource_name │ -│ action │ -│ scope │ -│ field_name (nullable) │ -│ granted │ -└─────────────────────────────┘ - -┌─────────────────────────────┐ -│ permission_set_pages │ -│─────────────────────────────│ -│ id (PK) │ -│ permission_set_id (FK) │───┐ -│ page_path │ │ -└─────────────────────────────┘ │ - │ - │ -┌─────────────────────┐ │ -│ roles │ │ -│─────────────────────│ │ -│ id (PK) │ │ -│ name │ │ -│ description │ │ -│ permission_set_id │──────────┘ -│ is_system_role │ -└─────────────────────┘ - ▲ - │ - │ -┌─────────────────────┐ -│ users │ -│─────────────────────│ -│ id (PK) │ -│ email │ -│ hashed_password │ -│ oidc_id │ -│ member_id (FK) │ -│ role_id (FK) │◄──── Default: "Mitglied" role -└─────────────────────┘ +┌─────────────────────────────────┐ +│ users │ +├─────────────────────────────────┤ +│ id (PK, UUID) │ +│ email │ +│ hashed_password │ +│ role_id (FK → roles.id) ◄───┼──┐ +│ ... │ │ +└─────────────────────────────────┘ │ + │ + │ +┌─────────────────────────────────┐ │ +│ roles │ │ +├─────────────────────────────────┤ │ +│ id (PK, UUID) │──┘ +│ name (unique) │ +│ description │ +│ permission_set_name (String) │───┐ +│ is_system_role (Boolean) │ │ +│ inserted_at │ │ +│ updated_at │ │ +└─────────────────────────────────┘ │ + │ + │ References one of: +┌─────────────────────────────────┐ │ - "own_data" +│ PermissionSets Module │◄──┘ - "read_only" +│ (Hardcoded in Code) │ - "normal_user" +├─────────────────────────────────┤ - "admin" +│ get_permissions(:own_data) │ +│ get_permissions(:read_only) │ +│ get_permissions(:normal_user) │ +│ get_permissions(:admin) │ +└─────────────────────────────────┘ ``` ### Table Definitions -#### `permission_sets` +#### roles -Defines the 4 core permission sets. These are system-defined and cannot be deleted. - -```sql -CREATE TABLE permission_sets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL UNIQUE, - description TEXT, - is_system BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - --- Indexes -CREATE INDEX idx_permission_sets_name ON permission_sets(name); -``` - -**Records:** -- `own_data` - Users can only access their own data -- `read_only` - Read access to all members and custom fields -- `normal_user` - Read and write access to members and custom fields -- `admin` - Full access to everything - ---- - -#### `permission_set_resources` - -Defines what actions each permission set can perform on which resources. - -```sql -CREATE TABLE permission_set_resources ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, - resource_name VARCHAR(255) NOT NULL, -- "Member", "User", "PropertyType", etc. - action VARCHAR(50) NOT NULL, -- "read", "create", "update", "destroy" - scope VARCHAR(50), -- NULL/"all", "own", "linked" - field_name VARCHAR(255), -- NULL = all fields, else specific field (Phase 2) - granted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - --- Indexes -CREATE INDEX idx_psr_permission_set ON permission_set_resources(permission_set_id); -CREATE INDEX idx_psr_resource_action ON permission_set_resources(resource_name, action); -CREATE UNIQUE INDEX idx_psr_unique ON permission_set_resources( - permission_set_id, resource_name, action, - COALESCE(scope, 'all'), COALESCE(field_name, '') -); -``` - -**Scope Values:** -- `NULL` or `"all"` - Permission applies to all entities of this resource -- `"own"` - Permission applies only to user's own data (user.id == actor.id) -- `"linked"` - Permission applies only to entities linked to user (e.g., member.user_id == actor.id) - -**Field Name (Phase 2):** -- `NULL` - Permission applies to all fields (Phase 1 default) -- `"field_name"` - Permission applies only to specific field (Phase 2) - -**Example Records:** -```sql --- Own Data Permission Set: User can read their own User record -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (own_data_id, 'User', 'read', 'own', true); - --- Read-Only Permission Set: Can read all Members -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (read_only_id, 'Member', 'read', 'all', true); - --- Normal User Permission Set: Can update all Members -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (normal_user_id, 'Member', 'update', 'all', true); - --- Admin Permission Set: Can destroy all Members -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (admin_id, 'Member', 'destroy', 'all', true); -``` - ---- - -#### `permission_set_pages` - -Defines which LiveView pages each permission set can access. - -```sql -CREATE TABLE permission_set_pages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, - page_path VARCHAR(255) NOT NULL, -- "/members", "/members/:id/edit", "/admin" - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - --- Indexes -CREATE INDEX idx_psp_permission_set ON permission_set_pages(permission_set_id); -CREATE INDEX idx_psp_page_path ON permission_set_pages(page_path); -CREATE UNIQUE INDEX idx_psp_unique ON permission_set_pages(permission_set_id, page_path); -``` - -**Page Paths:** -- Static paths: `/members`, `/users`, `/admin` -- Dynamic paths: `/members/:id`, `/members/:id/edit` -- Must match Phoenix Router routes exactly - -**Important:** Page permissions are READ-ONLY access checks. If a user shouldn't access an edit page, they don't get the page permission. The actual write operation is controlled by resource permissions. - -**Example Records:** -```sql --- Own Data: Only profile page -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES (own_data_id, '/profile'); - --- Read-Only: Member index and show pages -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES - (read_only_id, '/members'), - (read_only_id, '/members/:id'); - --- Normal User: Member pages including edit -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES - (normal_user_id, '/members'), - (normal_user_id, '/members/new'), - (normal_user_id, '/members/:id'), - (normal_user_id, '/members/:id/edit'); - --- Admin: All pages -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES - (admin_id, '/members'), - (admin_id, '/members/new'), - (admin_id, '/members/:id'), - (admin_id, '/members/:id/edit'), - (admin_id, '/users'), - (admin_id, '/users/new'), - (admin_id, '/users/:id'), - (admin_id, '/users/:id/edit'), - (admin_id, '/admin'); -``` - ---- - -#### `roles` - -Defines user roles that reference one permission set each. +Stores role definitions that reference permission sets by name. ```sql CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL UNIQUE, description TEXT, - permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE RESTRICT, + permission_set_name VARCHAR(50) NOT NULL, is_system_role BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() + inserted_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT check_valid_permission_set + CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin')) ); --- Indexes -CREATE INDEX idx_roles_name ON roles(name); -CREATE INDEX idx_roles_permission_set ON roles(permission_set_id); +CREATE UNIQUE INDEX roles_name_index ON roles (name); +CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name); ``` -**System Roles:** -- `is_system_role = true` for "Mitglied" (default role) -- System roles cannot be deleted -- Can be renamed but must always exist +**Fields:** +- `name` - Display name (e.g., "Vorstand", "Admin") +- `description` - Human-readable description +- `permission_set_name` - References hardcoded permission set +- `is_system_role` - If true, role cannot be deleted (protects "Mitglied") -**Example Records:** -```sql --- Mitglied (default role for all users) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Mitglied', 'Standard role for all members', own_data_id, true); +**Constraints:** +- `name` must be unique +- `permission_set_name` must be one of 4 valid values +- System roles cannot be deleted (enforced in Ash resource) --- Vorstand (board member with read access) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Vorstand', 'Board member with read access to all members', read_only_id, false); +#### users (modified) --- Kassenwart (treasurer with write access + payment info) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Kassenwart', 'Treasurer with access to payment information', normal_user_id, false); - --- Buchhaltung (accounting with read access) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Buchhaltung', 'Accounting with read-only access', read_only_id, false); - --- Admin (full access) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Admin', 'Full administrative access', admin_id, false); -``` - ---- - -#### `users` (Extended) - -Add `role_id` foreign key to existing users table. +Add foreign key to roles table. ```sql -ALTER TABLE users -ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; +ALTER TABLE users + ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; --- Set default to "Mitglied" role (via migration) -UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL; - -ALTER TABLE users ALTER COLUMN role_id SET NOT NULL; - --- Index -CREATE INDEX idx_users_role ON users(role_id); +CREATE INDEX users_role_id_index ON users (role_id); ``` ---- +**ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it. -## Permission System Design +### Seed Data -### Permission Evaluation Flow +Five predefined roles created during initial setup: -``` -Request comes in (LiveView mount or Ash action) - ↓ -1. Load Current User with Role preloaded - ↓ -2. Check Page Permission (if LiveView) - - Query: permission_set_pages WHERE page_path = current_path - - If no match: DENY, redirect to "/" - ↓ -3. Ash Policy Check (for resource actions) - - Policy 1: Check "relates_to_actor" (own data) - - Policy 2: Check custom permission via DB - - Load permission_set_resources - - Match: resource_name, action, scope - - Evaluate scope: - * "own" → Filter: id == actor.id - * "linked" → Filter: user_id == actor.id - * "all" → No filter - - Policy 3: Default DENY - ↓ -4. Special Validations (if applicable) - - Member email change on linked member - - Required fields on create - ↓ -5. Execute Action or Render Page -``` - -### Scope Evaluation - -The `scope` field determines which subset of records a permission applies to: - -#### Scope: `"own"` - -Used for resources where user has direct ownership. - -**Applicable to:** `User` - -**Filter Logic:** ```elixir -{:filter, expr(id == ^actor.id)} -``` +# priv/repo/seeds/authorization_seeds.exs -**Example:** -- Own Data permission set has `User.read` with scope `"own"` -- User can only read their own User record -- Query becomes: `SELECT * FROM users WHERE id = $actor_id` +roles = [ + %{ + name: "Mitglied", + description: "Default member role with access to own data only", + permission_set_name: "own_data", + is_system_role: true # Cannot be deleted! + }, + %{ + name: "Vorstand", + description: "Board member with read access to all member data", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Kassenwart", + description: "Treasurer with full member and payment management", + permission_set_name: "normal_user", + is_system_role: false + }, + %{ + name: "Buchhaltung", + description: "Accounting with read-only access for auditing", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Admin", + description: "Administrator with unrestricted access", + permission_set_name: "admin", + is_system_role: false + } +] + +# Create roles with idempotent logic +Enum.each(roles, fn role_data -> + case Ash.get(Mv.Authorization.Role, name: role_data.name) do + {:ok, existing_role} -> + # Update if exists + Ash.update!(existing_role, role_data) + {:error, _} -> + # Create if not exists + Ash.create!(Mv.Authorization.Role, role_data) + end +end) + +# Assign "Mitglied" role to users without role +mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied") +users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id))) + +Enum.each(users_without_role, fn user -> + Ash.update!(user, %{role_id: mitglied_role.id}) +end) +``` --- -#### Scope: `"linked"` +## Permission System Design (MVP) -Used for resources linked to user via intermediate relationship. +### PermissionSets Module -**Applicable to:** `Member`, `Property`, `Payment` (future) +**Location:** `lib/mv/authorization/permission_sets.ex` + +This module is the **single source of truth** for all permissions in the MVP. It defines what each permission set can do. + +#### Module Structure -**Filter Logic:** ```elixir -# For Member -{:filter, expr(user_id == ^actor.id)} +defmodule Mv.Authorization.PermissionSets do + @moduledoc """ + Defines the four hardcoded permission sets for the application. + + Each permission set specifies: + - Resource permissions (what CRUD operations on which resources) + - Page permissions (which LiveView pages can be accessed) + - Scopes (own, linked, all) + + ## Permission Sets + + 1. **own_data** - Default for "Mitglied" role + - Can only access own user data and linked member/properties + - Cannot create new members or manage system + + 2. **read_only** - For "Vorstand" and "Buchhaltung" roles + - Can read all member data + - Cannot create, update, or delete + + 3. **normal_user** - For "Kassenwart" role + - Create/Read/Update members (no delete), full CRUD on properties + - Cannot manage property types or users + + 4. **admin** - For "Admin" role + - Unrestricted access to all resources + - Can manage users, roles, property types + + ## Usage + + # Get permissions for a role's permission set + permissions = PermissionSets.get_permissions(:admin) + + # Check if a permission set name is valid + PermissionSets.valid_permission_set?("read_only") # => true + + # Convert string to atom safely + {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data") + + ## Performance + + All functions are pure and compile-time. Permission lookups are < 1 microsecond. + """ -# For Property (traverses relationship) -{:filter, expr(member.user_id == ^actor.id)} + @type scope :: :own | :linked | :all + @type action :: :read | :create | :update | :destroy + + @type resource_permission :: %{ + resource: String.t(), + action: action(), + scope: scope(), + granted: boolean() + } + + @type permission_set :: %{ + resources: [resource_permission()], + pages: [String.t()] + } -# For Payment (future, traverses relationship) -{:filter, expr(member.user_id == ^actor.id)} + @doc """ + Returns the list of all valid permission set names. + + ## Examples + + iex> PermissionSets.all_permission_sets() + [:own_data, :read_only, :normal_user, :admin] + """ + @spec all_permission_sets() :: [atom()] + def all_permission_sets do + [:own_data, :read_only, :normal_user, :admin] + end + + @doc """ + Returns permissions for the given permission set. + + ## Examples + + iex> permissions = PermissionSets.get_permissions(:admin) + iex> Enum.any?(permissions.resources, fn p -> + ...> p.resource == "User" and p.action == :destroy + ...> end) + true + + iex> PermissionSets.get_permissions(:invalid) + ** (FunctionClauseError) no function clause matching + """ + @spec get_permissions(atom()) :: permission_set() + + def get_permissions(:own_data) do + %{ + resources: [ + # User: Can always read/update own credentials + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Can read/update linked member + %{resource: "Member", action: :read, scope: :linked, granted: true}, + %{resource: "Member", action: :update, scope: :linked, granted: true}, + + # Property: Can read/update properties of linked member + %{resource: "Property", action: :read, scope: :linked, granted: true}, + %{resource: "Property", action: :update, scope: :linked, granted: true}, + + # PropertyType: Can read all (needed for forms) + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", # Home page + "/profile", # Own profile + "/members/:id" # Linked member detail (filtered by policy) + ] + } + end + + def get_permissions(:read_only) do + %{ + resources: [ + # User: Can read/update own credentials only + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Can read all members, no modifications + %{resource: "Member", action: :read, scope: :all, granted: true}, + + # Property: Can read all properties + %{resource: "Property", action: :read, scope: :all, granted: true}, + + # PropertyType: Can read all + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", + "/members", # Member list + "/members/:id", # Member detail + "/properties", # Property overview + "/profile" # Own profile + ] + } + end + + def get_permissions(:normal_user) do + %{ + resources: [ + # User: Can read/update own credentials only + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Full CRUD + %{resource: "Member", action: :read, scope: :all, granted: true}, + %{resource: "Member", action: :create, scope: :all, granted: true}, + %{resource: "Member", action: :update, scope: :all, granted: true}, + # Note: destroy intentionally omitted for safety + + # Property: Full CRUD + %{resource: "Property", action: :read, scope: :all, granted: true}, + %{resource: "Property", action: :create, scope: :all, granted: true}, + %{resource: "Property", action: :update, scope: :all, granted: true}, + %{resource: "Property", action: :destroy, scope: :all, granted: true}, + + # PropertyType: Read only (admin manages definitions) + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", + "/members", + "/members/new", # Create member + "/members/:id", + "/members/:id/edit", # Edit member + "/properties", + "/properties/new", + "/properties/:id/edit", + "/profile" + ] + } + end + + def get_permissions(:admin) do + %{ + resources: [ + # User: Full management including other users + %{resource: "User", action: :read, scope: :all, granted: true}, + %{resource: "User", action: :create, scope: :all, granted: true}, + %{resource: "User", action: :update, scope: :all, granted: true}, + %{resource: "User", action: :destroy, scope: :all, granted: true}, + + # Member: Full CRUD + %{resource: "Member", action: :read, scope: :all, granted: true}, + %{resource: "Member", action: :create, scope: :all, granted: true}, + %{resource: "Member", action: :update, scope: :all, granted: true}, + %{resource: "Member", action: :destroy, scope: :all, granted: true}, + + # Property: Full CRUD + %{resource: "Property", action: :read, scope: :all, granted: true}, + %{resource: "Property", action: :create, scope: :all, granted: true}, + %{resource: "Property", action: :update, scope: :all, granted: true}, + %{resource: "Property", action: :destroy, scope: :all, granted: true}, + + # PropertyType: Full CRUD (admin manages custom field definitions) + %{resource: "PropertyType", action: :read, scope: :all, granted: true}, + %{resource: "PropertyType", action: :create, scope: :all, granted: true}, + %{resource: "PropertyType", action: :update, scope: :all, granted: true}, + %{resource: "PropertyType", action: :destroy, scope: :all, granted: true}, + + # Role: Full CRUD (admin manages roles) + %{resource: "Role", action: :read, scope: :all, granted: true}, + %{resource: "Role", action: :create, scope: :all, granted: true}, + %{resource: "Role", action: :update, scope: :all, granted: true}, + %{resource: "Role", action: :destroy, scope: :all, granted: true} + ], + pages: [ + "*" # Wildcard: Admin can access all pages + ] + } + end + + @doc """ + Checks if a permission set name (string or atom) is valid. + + ## Examples + + iex> PermissionSets.valid_permission_set?("admin") + true + + iex> PermissionSets.valid_permission_set?(:read_only) + true + + iex> PermissionSets.valid_permission_set?("invalid") + false + """ + @spec valid_permission_set?(String.t() | atom()) :: boolean() + def valid_permission_set?(name) when is_binary(name) do + case permission_set_name_to_atom(name) do + {:ok, _atom} -> true + {:error, _} -> false + end + end + + def valid_permission_set?(name) when is_atom(name) do + name in all_permission_sets() + end + + @doc """ + Converts a permission set name string to atom safely. + + ## Examples + + iex> PermissionSets.permission_set_name_to_atom("admin") + {:ok, :admin} + + iex> PermissionSets.permission_set_name_to_atom("invalid") + {:error, :invalid_permission_set} + """ + @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set} + def permission_set_name_to_atom(name) when is_binary(name) do + atom = String.to_existing_atom(name) + if valid_permission_set?(atom) do + {:ok, atom} + else + {:error, :invalid_permission_set} + end + rescue + ArgumentError -> {:error, :invalid_permission_set} + end +end ``` -**Example:** -- Own Data permission set has `Member.read` with scope `"linked"` -- User can only read Members linked to them (member.user_id == actor.id) -- If user has no linked member: no results -- Query becomes: `SELECT * FROM members WHERE user_id = $actor_id` +#### Permission Matrix ---- +Quick reference table showing what each permission set allows: -#### Scope: `"all"` or `NULL` +| Resource | own_data | read_only | normal_user | admin | +|----------|----------|-----------|-------------|-------| +| **User** (own) | R, U | R, U | R, U | R, U | +| **User** (all) | - | - | - | R, C, U, D | +| **Member** (linked) | R, U | - | - | - | +| **Member** (all) | - | R | R, C, U | R, C, U, D | +| **Property** (linked) | R, U | - | - | - | +| **Property** (all) | - | R | R, C, U, D | R, C, U, D | +| **PropertyType** (all) | R | R | R | R, C, U, D | +| **Role** (all) | - | - | - | R, C, U, D | -Used for full access to all records of a resource. +**Legend:** R=Read, C=Create, U=Update, D=Destroy -**Applicable to:** All resources +### HasPermission Policy Check + +**Location:** `lib/mv/authorization/checks/has_permission.ex` + +This is a custom Ash Policy Check that evaluates permissions from the `PermissionSets` module. -**Filter Logic:** ```elixir -:authorized # No filter, all records allowed +defmodule Mv.Authorization.Checks.HasPermission do + @moduledoc """ + Custom Ash Policy Check that evaluates permissions from the PermissionSets module. + + This check: + 1. Reads the actor's role and permission_set_name + 2. Looks up permissions from PermissionSets.get_permissions/1 + 3. Finds matching permission for current resource + action + 4. Applies scope filter (:own, :linked, :all) + + ## Usage in Ash Resource + + policies do + policy action_type(:read) do + authorize_if Mv.Authorization.Checks.HasPermission + end + end + + ## Scope Behavior + + - **:all** - Authorizes without filtering (returns all records) + - **:own** - Filters to records where record.id == actor.id + - **:linked** - Filters based on resource type: + - Member: member.user_id == actor.id + - Property: property.member.user_id == actor.id (traverses relationship!) + + ## Error Handling + + Returns `{:error, reason}` for: + - Missing actor + - Actor without role + - Invalid permission_set_name + - No matching permission found + + All errors result in Forbidden (policy fails). + """ + + use Ash.Policy.Check + require Ash.Query + import Ash.Expr + alias Mv.Authorization.PermissionSets + + @impl true + def describe(_opts) do + "checks if actor has permission via their role's permission set" + end + + @impl true + def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do + with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom), + resource_name <- get_resource_name(resource) do + check_permission(permissions.resources, resource_name, action, actor, resource_name) + else + %{role: nil} -> + log_auth_failure(actor, resource, action, "no role assigned") + {:error, :no_role} + + %{role: %{permission_set_name: nil}} -> + log_auth_failure(actor, resource, action, "role has no permission_set_name") + {:error, :no_permission_set} + + {:error, :invalid_permission_set} = error -> + log_auth_failure(actor, resource, action, "invalid permission_set_name") + error + + _ -> + log_auth_failure(actor, resource, action, "no actor or missing data") + {:error, :no_permission} + end + end + + # Extract resource name from module (e.g., Mv.Membership.Member -> "Member") + defp get_resource_name(resource) when is_atom(resource) do + resource |> Module.split() |> List.last() + end + + # Find matching permission and apply scope + defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do + case Enum.find(resource_perms, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) do + nil -> + {:error, :no_permission} + + perm -> + apply_scope(perm.scope, actor, resource_name) + end + end + + # Scope: all - No filtering, access to all records + defp apply_scope(:all, _actor, _resource) do + :authorized + end + + # Scope: own - Filter to records where record.id == actor.id + # Used for User resource (users can access their own user record) + defp apply_scope(:own, actor, _resource) do + {:filter, expr(id == ^actor.id)} + end + + # Scope: linked - Filter based on user_id relationship (resource-specific!) + defp apply_scope(:linked, actor, resource_name) do + case resource_name do + "Member" -> + # Member.user_id == actor.id (direct relationship) + {:filter, expr(user_id == ^actor.id)} + + "Property" -> + # Property.member.user_id == actor.id (traverse through member!) + {:filter, expr(member.user_id == ^actor.id)} + + _ -> + # Fallback for other resources: try direct user_id + {:filter, expr(user_id == ^actor.id)} + end + end + + # Log authorization failures for debugging + defp log_auth_failure(actor, resource, action, reason) do + require Logger + + actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" + resource_name = get_resource_name(resource) + + Logger.debug(""" + Authorization failed: + Actor: #{actor_id} + Resource: #{resource_name} + Action: #{action} + Reason: #{reason} + """) + end +end ``` -**Example:** -- Read-Only permission set has `Member.read` with scope `"all"` -- User can read all Members -- Query becomes: `SELECT * FROM members` (no WHERE clause for authorization) +**Key Design Decisions:** + +1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id` +2. **Error Handling:** All errors log for debugging but return generic forbidden to user +3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings +4. **Pure Function:** No side effects, deterministic, easily testable --- -### Policy Implementation in Ash Resources +## Resource Policies -Each Ash resource defines policies that check permissions: +Each Ash resource defines policies that use the `HasPermission` check. This section documents the policy structure for each resource. + +### General Policy Pattern + +**All resources follow this pattern:** + +```elixir +policies do + # 1. Special cases first (most specific) + policy action_type(:read) do + authorize_if expr(condition_for_special_case) + end + + # 2. General authorization (uses PermissionSets) + policy action_type([:read, :create, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission + end + + # 3. Default: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end +end +``` + +**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins. + +### User Resource Policies + +**Location:** `lib/mv/accounts/user.ex` + +**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role. + +```elixir +defmodule Mv.Accounts.User do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can always access their own account + # This takes precedence over permission checks + policy action_type([:read, :update]) do + description "Users can always read and update their own account" + authorize_if expr(id == ^actor(:id)) + end + + # GENERAL: Other operations require permission + # (e.g., admin reading/updating other users, admin destroying users) + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid if no policy matched + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | +| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | +| Read others | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update others | ❌ | ❌ | ❌ | ❌ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Member Resource Policies + +**Location:** `lib/mv/membership/member.ex` + +**Special Case:** Users can always access their linked member (where `member.user_id == user.id`). ```elixir defmodule Mv.Membership.Member do use Ash.Resource, ... policies do - # Policy 1: Users can always access their own linked member data - # This bypasses permission checks for own data + # SPECIAL CASE: Users can always access their linked member policy action_type([:read, :update]) do - description "Users can always access their own member data if linked" - authorize_if relates_to_actor_via(:user) + description "Users can access member linked to their account" + authorize_if expr(user_id == ^actor(:id)) end - # Policy 2: Check database permissions - # This is where permission_set_resources table is queried + # GENERAL: Check permissions from role policy action_type([:read, :create, :update, :destroy]) do - description "Check if actor's role has permission for this action" - authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission end - # Policy 3: Default deny - # If no policy matched, forbid access + # DEFAULT: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end -end -``` - -**Important:** Policy order matters! First matching policy wins. - ---- - -### Custom Policy Check Implementation - -```elixir -defmodule Mv.Authorization.Checks.HasResourcePermission do - @moduledoc """ - Custom Ash Policy Check that evaluates database-stored permissions. - Queries the permission_set_resources table based on actor's role - and evaluates scope to return appropriate filter. - """ - - use Ash.Policy.Check - - @impl true - def type, do: :filter - - @impl true - def match?(actor, context, _opts) do - resource = context.resource - action = context.action - - # Load actor's permission set (with caching) - case get_permission_set(actor) do - nil -> - :forbidden - - permission_set -> - # Query permission_set_resources table - check_permission(permission_set.id, resource, action.name, actor, context) - end - end - - defp get_permission_set(nil), do: nil - defp get_permission_set(actor) do - # Try cache first (ETS) - case Mv.Authorization.PermissionCache.get_permission_set(actor.id) do - {:ok, permission_set} -> - permission_set - - :miss -> - # Load from database: user → role → permission_set - load_and_cache_permission_set(actor) - end - end - - defp load_and_cache_permission_set(actor) do - case Ash.load(actor, role: :permission_set) do - {:ok, user_with_relations} -> - permission_set = user_with_relations.role.permission_set - Mv.Authorization.PermissionCache.put_permission_set(actor.id, permission_set) - permission_set - - _ -> - nil - end - end - - defp check_permission(permission_set_id, resource, action, actor, context) do - resource_name = resource |> Module.split() |> List.last() - - # Query permission_set_resources - query = - Mv.Authorization.PermissionSetResource - |> Ash.Query.filter( - permission_set_id == ^permission_set_id and - resource_name == ^resource_name and - action == ^action and - is_nil(field_name) # Phase 1: only resource-level - ) - - case Ash.read_one(query) do - {:ok, permission} -> - evaluate_permission(permission, actor, context) - - _ -> - :forbidden - end - end - - defp evaluate_permission(%{granted: false}, _actor, _context) do - :forbidden - end - - defp evaluate_permission(%{granted: true, scope: nil}, _actor, _context) do - :authorized - end - - defp evaluate_permission(%{granted: true, scope: "all"}, _actor, _context) do - :authorized - end - - defp evaluate_permission(%{granted: true, scope: "own"}, actor, _context) do - # Return filter expression for Ash - {:filter, expr(id == ^actor.id)} - end - - defp evaluate_permission(%{granted: true, scope: "linked"}, actor, context) do - resource = context.resource - - # Generate appropriate filter based on resource - case resource do - Mv.Membership.Member -> - {:filter, expr(user_id == ^actor.id)} - - Mv.Membership.Property -> - {:filter, expr(member.user_id == ^actor.id)} - - # Add more resources as needed - - _ -> - :forbidden - end - end -end -``` - ---- - -## Implementation Details - -### Phase 1: Resource and Page Level Permissions - -**Timeline:** Sprint 1-2 (2-3 weeks) - -**Deliverables:** -1. Database migrations for all permission tables -2. Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage -3. Custom policy checks -4. Permission cache (ETS) -5. Router plug for page permissions -6. Seeds for 4 permission sets and 5 roles -7. Admin UI for role management -8. Tests for all permission scenarios - -**Not Included in Phase 1:** -- Field-level permissions (field_name is always NULL) -- Payment history (resource doesn't exist yet) -- Groups (not yet planned) - ---- - -### Special Cases Implementation - -#### 1. User Credentials - Always Editable by Owner - -**Requirement:** Users can always edit their own email and password, regardless of permission set. - -**Implementation:** - -```elixir -defmodule Mv.Accounts.User do - policies do - # Policy 1: Users can ALWAYS read and update their own credentials - # This comes BEFORE permission checks - policy action_type([:read, :update]) do - description "Users can always access and update their own credentials" - authorize_if expr(id == ^actor(:id)) - end - - # Policy 2: Check permission set (for admins accessing other users) - policy action_type([:read, :create, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() - end - - # Policy 3: Default deny - policy action_type([:read, :create, :update, :destroy]) do - forbid_if always() - end - end -end -``` - -**Result:** -- Mitglied role: Can edit own User record (own email/password) -- Admin role: Can edit ANY User record (including others' credentials) -- Other roles: Cannot access User resource unless specifically granted - ---- - -#### 2. Member Email for Linked Members - Admin Only - -**Requirement:** If a member is linked to a user, only admins can edit the member's email field. - -**Implementation:** - -```elixir -defmodule Mv.Membership.Member do + # Custom validation for email editing (see Special Cases section) validations do - validate fn changeset, context -> - # Only check if email is being changed - if Ash.Changeset.changing_attribute?(changeset, :email) do - member = changeset.data - actor = context.actor - - # Load member's user relationship - case Ash.load(member, :user) do - {:ok, %{user: %{id: _user_id}}} -> - # Member IS linked to a user - # Check if actor has permission to edit ALL users - if has_permission_for_all_users?(actor) do - :ok - else - {:error, - field: :email, - message: "Only admins can edit email of members linked to users"} - end - - {:ok, %{user: nil}} -> - # Member is NOT linked - # Normal Member.update permission applies - :ok - - {:error, _} -> - :ok - end - else - :ok - end + validate changing(:email), on: :update do + validate &validate_linked_member_email_change/2 end end - defp has_permission_for_all_users?(actor) do - # Check if actor's permission set has User.update with scope="all" - permission_set = get_permission_set(actor) - - Mv.Authorization.PermissionSetResource - |> Ash.Query.filter( - permission_set_id == ^permission_set.id and - resource_name == "User" and - action == "update" and - scope == "all" and - granted == true - ) - |> Ash.exists?() - end + # ... end ``` -**Result:** -- Admin: Can edit email of any member (including linked ones) -- Normal User/Read-Only: Can edit email of unlinked members only -- Attempting to edit email of linked member without permission: Validation error +**Permission Matrix:** ---- +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special)* | ❌ | ✅* | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | -#### 3. Required Custom Fields on Member Creation +*Email editing has additional validation (see Special Cases) -**Requirement:** When creating a member with required custom fields, user must be able to set those fields even if they normally don't have permission. +### Property Resource Policies -**Implementation:** +**Location:** `lib/mv/membership/property.ex` -For Phase 1, this is not an issue because: -- PropertyType.required flag exists but isn't enforced yet -- No field-level permissions exist yet -- If user has Member.create permission, they can set Properties - -For Phase 2 (when field-level permissions exist): +**Special Case:** Users can access properties of their linked member. ```elixir defmodule Mv.Membership.Property do - actions do - create :create_property do - # Special handling for required properties during member creation - change Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate - end - end -end - -defmodule Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate do - use Ash.Resource.Change + use Ash.Resource, ... - def change(changeset, _opts, context) do - # Check if this is part of a member creation - if creating_member?(context) do - property_type_id = Ash.Changeset.get_attribute(changeset, :property_type_id) - - # Load PropertyType - case Ash.get(Mv.Membership.PropertyType, property_type_id) do - {:ok, %{required: true}} -> - # This is a required field, allow creation even without normal permission - # Set special context flag - Ash.Changeset.set_context(changeset, :bypass_property_permission, true) - - _ -> - changeset - end - else - changeset + policies do + # SPECIAL CASE: Users can access properties of their linked member + # Note: This traverses the member relationship! + policy action_type([:read, :update]) do + description "Users can access properties of their linked member" + authorize_if expr(member.user_id == ^actor(:id)) + end + + # GENERAL: Check permissions from role + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() end end + + # ... end ``` +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ | + +### PropertyType Resource Policies + +**Location:** `lib/mv/membership/property_type.ex` + +**No Special Cases:** All users can read, only admin can write. + +```elixir +defmodule Mv.Membership.PropertyType do + use Ash.Resource, ... + + policies do + # All authenticated users can read property types (needed for forms) + # Write operations are admin-only + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read | ✅ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Role Resource Policies + +**Location:** `lib/mv/authorization/role.ex` + +**Special Protection:** System roles cannot be deleted. + +```elixir +defmodule Mv.Authorization.Role do + use Ash.Resource, ... + + policies do + # Only admin can manage roles + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # Prevent deletion of system roles + validations do + validate action(:destroy) do + validate fn _changeset, %{data: role} -> + if role.is_system_role do + {:error, "Cannot delete system role"} + else + :ok + end + end + end + end + + # Validate permission_set_name + validations do + validate attribute(:permission_set_name) do + validate fn _changeset, value -> + if PermissionSets.valid_permission_set?(value) do + :ok + else + {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} + end + end + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read | ❌ | ❌ | ❌ | ❌ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ | + +*Cannot destroy if `is_system_role=true` + --- -### Page Permission Implementation +## Page Permission System -**Router Configuration:** +Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug. -```elixir -defmodule MvWeb.Router do - use MvWeb, :router - import MvWeb.Authorization - - # Pipeline with permission check - pipeline :require_page_permission do - plug :put_secure_browser_headers - plug :fetch_current_user - plug MvWeb.Plugs.CheckPagePermission - end - - scope "/", MvWeb do - pipe_through [:browser, :require_authenticated_user, :require_page_permission] - - # These routes automatically check page permissions - live "/members", MemberLive.Index, :index - live "/members/new", MemberLive.Index, :new - live "/members/:id", MemberLive.Show, :show - live "/members/:id/edit", MemberLive.Index, :edit - - live "/users", UserLive.Index, :index - live "/users/new", UserLive.Index, :new - live "/users/:id", UserLive.Show, :show - live "/users/:id/edit", UserLive.Index, :edit - - live "/property-types", PropertyTypeLive.Index, :index - live "/property-types/new", PropertyTypeLive.Index, :new - live "/property-types/:id/edit", PropertyTypeLive.Index, :edit - - live "/admin", AdminLive.Dashboard, :index - end -end -``` +### CheckPagePermission Plug -**Page Permission Plug:** +**Location:** `lib/mv_web/plugs/check_page_permission.ex` + +This plug runs in the router pipeline and checks if the current user has permission to access the requested page. ```elixir defmodule MvWeb.Plugs.CheckPagePermission do @moduledoc """ Plug that checks if current user has permission to access the current page. - Queries permission_set_pages table based on user's role → permission_set. + ## How It Works + + 1. Extracts page path from conn (route template like "/members/:id") + 2. Gets current user from conn.assigns + 3. Gets user's permission_set_name from role + 4. Calls PermissionSets.get_permissions/1 to get allowed pages + 5. Matches requested path against allowed patterns + 6. If unauthorized: redirects to "/" with flash error + + ## Pattern Matching + + - Exact match: "/members" == "/members" + - Dynamic routes: "/members/:id" matches "/members/123" + - Wildcard: "*" matches everything (admin) + + ## Usage in Router + + pipeline :require_page_permission do + plug MvWeb.Plugs.CheckPagePermission + end + + scope "/members", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/", MemberLive.Index + live "/:id", MemberLive.Show + end """ import Plug.Conn import Phoenix.Controller - + alias Mv.Authorization.PermissionSets + require Logger + def init(opts), do: opts - + def call(conn, _opts) do user = conn.assigns[:current_user] page_path = get_page_path(conn) @@ -1060,425 +1171,386 @@ defmodule MvWeb.Plugs.CheckPagePermission do if has_page_permission?(user, page_path) do conn else + log_page_access_denied(user, page_path) + conn |> put_flash(:error, "You don't have permission to access this page.") |> redirect(to: "/") |> halt() end end - + + # Extract page path from conn (route template preferred, fallback to request_path) defp get_page_path(conn) do - # Extract route template from conn - # "/members/:id/edit" from actual "/members/123/edit" case conn.private[:phoenix_route] do - {_, _, _, route_template, _} -> route_template - _ -> conn.request_path - end - end - - defp has_page_permission?(nil, _page_path), do: false - defp has_page_permission?(user, page_path) do - # Try cache first - case Mv.Authorization.PermissionCache.get_page_permission(user.id, page_path) do - {:ok, has_permission} -> - has_permission - - :miss -> - # Load from database and cache - has_permission = check_page_permission_db(user, page_path) - Mv.Authorization.PermissionCache.put_page_permission(user.id, page_path, has_permission) - has_permission - end - end - - defp check_page_permission_db(user, page_path) do - # Load user → role → permission_set - case Ash.load(user, role: :permission_set) do - {:ok, user_with_relations} -> - permission_set_id = user_with_relations.role.permission_set.id - - # Check if permission_set_pages has this page - Mv.Authorization.PermissionSetPage - |> Ash.Query.filter( - permission_set_id == ^permission_set_id and - page_path == ^page_path - ) - |> Ash.exists?() + {_plug, _opts, _pipe, route_template, _meta} -> + route_template _ -> - false + conn.request_path end end -end -``` -**Important:** Both page permission AND resource permission must be true for edit operations: -- User needs `/members/:id/edit` page permission to see the page -- User needs `Member.update` permission to actually save changes -- If user has page permission but not resource permission: page loads but save fails - ---- - -### Permission Cache Implementation - -**ETS Cache for Performance:** - -```elixir -defmodule Mv.Authorization.PermissionCache do - @moduledoc """ - ETS-based cache for user permissions to avoid database lookups on every request. - - Cache stores: - - User's permission_set (user_id → permission_set) - - Page permissions (user_id + page_path → boolean) - - Resource permissions (user_id + resource + action → permission) - - Cache is invalidated when: - - User's role changes - - Role's permission_set changes - - Permission set's permissions change - """ - - use GenServer - - @table_name :permission_cache - - # Client API - - def start_link(_) do - GenServer.start_link(__MODULE__, [], name: __MODULE__) + # Check if user has permission for page + defp has_page_permission?(nil, _page_path) do + false end - def get_permission_set(user_id) do - case :ets.lookup(@table_name, {:permission_set, user_id}) do - [{_, permission_set}] -> {:ok, permission_set} - [] -> :miss + defp has_page_permission?(user, page_path) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + page_matches?(permissions.pages, page_path) + else + _ -> false end end - - def put_permission_set(user_id, permission_set) do - :ets.insert(@table_name, {{:permission_set, user_id}, permission_set}) - :ok - end - - def get_page_permission(user_id, page_path) do - case :ets.lookup(@table_name, {:page, user_id, page_path}) do - [{_, has_permission}] -> {:ok, has_permission} - [] -> :miss - end - end - - def put_page_permission(user_id, page_path, has_permission) do - :ets.insert(@table_name, {{:page, user_id, page_path}, has_permission}) - :ok - end - - def invalidate_user(user_id) do - # Delete all entries for this user - :ets.match_delete(@table_name, {{:permission_set, user_id}, :_}) - :ets.match_delete(@table_name, {{:page, user_id, :_}, :_}) - :ok - end - - def invalidate_all do - :ets.delete_all_objects(@table_name) - :ok - end - - # Server Callbacks - - def init(_) do - table = :ets.new(@table_name, [ - :set, - :public, - :named_table, - read_concurrency: true, - write_concurrency: true - ]) - {:ok, %{table: table}} - end -end -``` -**Cache Invalidation Strategy:** - -```elixir -defmodule Mv.Authorization.Role do - # After updating role's permission_set - changes do - change after_action(fn changeset, role, _context -> - # Invalidate cache for all users with this role - invalidate_users_with_role(role.id) - {:ok, role} - end), on: [:update] - end - - defp invalidate_users_with_role(role_id) do - # Find all users with this role - users = - Mv.Accounts.User - |> Ash.Query.filter(role_id == ^role_id) - |> Ash.read!() - - # Invalidate each user's cache - Enum.each(users, fn user -> - Mv.Authorization.PermissionCache.invalidate_user(user.id) + # Check if requested path matches any allowed pattern + defp page_matches?(allowed_pages, requested_path) do + Enum.any?(allowed_pages, fn pattern -> + cond do + # Wildcard: admin can access all pages + pattern == "*" -> + true + + # Exact match + pattern == requested_path -> + true + + # Dynamic route match (e.g., "/members/:id" matches "/members/123") + String.contains?(pattern, ":") -> + match_dynamic_route?(pattern, requested_path) + + # No match + true -> + false + end end) end -end -defmodule Mv.Authorization.PermissionSetResource do - # After updating permissions - changes do - change after_action(fn changeset, permission, _context -> - # Invalidate all users with this permission set - invalidate_permission_set(permission.permission_set_id) - {:ok, permission} - end), on: [:create, :update, :destroy] - end - - defp invalidate_permission_set(permission_set_id) do - # Find all roles with this permission set - roles = - Mv.Authorization.Role - |> Ash.Query.filter(permission_set_id == ^permission_set_id) - |> Ash.read!() + # Match dynamic route pattern against actual path + defp match_dynamic_route?(pattern, path) do + pattern_segments = String.split(pattern, "/", trim: true) + path_segments = String.split(path, "/", trim: true) - # Invalidate all users with these roles - Enum.each(roles, fn role -> - invalidate_users_with_role(role.id) - end) + # Must have same number of segments + if length(pattern_segments) == length(path_segments) do + Enum.zip(pattern_segments, path_segments) + |> Enum.all?(fn {pattern_seg, path_seg} -> + # Dynamic segment (starts with :) matches anything + String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg + end) + else + false + end + end + + defp log_page_access_denied(user, page_path) do + user_id = if is_map(user), do: Map.get(user, :id), else: "nil" + role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil" + + Logger.info(""" + Page access denied: + User: #{user_id} + Role: #{role} + Page: #{page_path} + """) end end ``` ---- +### Router Integration -### UI-Level Authorization +Add plug to protected routes: -**Requirement:** The user interface should only display links, buttons, and fields that the user has permission to access. This improves UX and prevents confusion. +```elixir +defmodule MvWeb.Router do + use MvWeb, :router -**Key Principles:** -1. **Navigation Links:** Hide links to pages the user cannot access -2. **Action Buttons:** Hide "Edit", "Delete", "New" buttons when user lacks permissions -3. **Form Fields:** In Phase 2, hide fields the user cannot read/write -4. **Proactive UI:** Never show a clickable element that would result in "Forbidden" + pipeline :require_page_permission do + plug MvWeb.Plugs.CheckPagePermission + end + + # Public routes (no authentication) + scope "/", MvWeb do + pipe_through :browser + + live "/", PageController, :home + get "/login", AuthController, :new + post "/login", AuthController, :create + end + + # Protected routes (authentication + page permission) + scope "/members", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/", MemberLive.Index, :index + live "/new", MemberLive.Form, :new + live "/:id", MemberLive.Show, :show + live "/:id/edit", MemberLive.Form, :edit + end + + # Admin routes + scope "/admin", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/roles", RoleLive.Index, :index + live "/roles/:id", RoleLive.Show, :show + end +end +``` + +### Page Permission Examples + +**Mitglied (own_data):** +- ✅ Can access: `/`, `/profile`, `/members/123` (if 123 is their linked member) +- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles` + +**Vorstand (read_only):** +- ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile` +- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` + +**Kassenwart (normal_user):** +- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile` +- ❌ Cannot access: `/admin/roles`, `/admin/property_types/new` + +**Admin:** +- ✅ Can access: `*` (all pages, including `/admin/roles`) --- -#### Implementation Approach +## UI-Level Authorization -**Helper Module:** `MvWeb.Authorization` +UI-level authorization ensures that users only see buttons, links, and form fields they have permission to use. This provides a consistent user experience and prevents confusing "forbidden" errors. + +### MvWeb.Authorization Helper Module + +**Location:** `lib/mv_web/authorization.ex` + +This module provides helper functions for conditional rendering in LiveView templates. ```elixir defmodule MvWeb.Authorization do @moduledoc """ - UI-level authorization helpers for LiveView. + UI-level authorization helpers for LiveView templates. - These helpers check permissions and determine what UI elements to show. - They work in conjunction with Ash Policies (which are the actual enforcement). + These functions check if the current user has permission to perform actions + or access pages. They use the same PermissionSets module as the backend policies, + ensuring UI and backend authorization are consistent. + + ## Usage in Templates + + + <%= if can?(@current_user, :create, Mv.Membership.Member) do %> + <.link patch={~p"/members/new"}>New Member + <% end %> + + + <%= if can?(@current_user, :update, @member) do %> + <.button>Edit + <% end %> + + + <%= if can_access_page?(@current_user, "/admin/roles") do %> + <.link navigate="/admin/roles">Manage Roles + <% end %> + + ## Performance + + All checks are pure function calls using the hardcoded PermissionSets module. + No database queries, < 1 microsecond per check. """ - alias Mv.Authorization.PermissionCache - alias Mv.Authorization - + alias Mv.Authorization.PermissionSets + @doc """ - Checks if actor can perform action on resource. + Checks if user has permission for an action on a resource (atom). ## Examples - # In LiveView template - <%= if can?(@current_user, :update, Mv.Membership.Member) do %> - - <% end %> + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can?(admin, :create, Mv.Membership.Member) + true - # In LiveView module - if can?(socket.assigns.current_user, :create, Mv.Membership.PropertyType) do - # Show "New Custom Field" button - end + iex> mitglied = %{role: %{permission_set_name: "own_data"}} + iex> can?(mitglied, :create, Mv.Membership.Member) + false """ + @spec can?(map() | nil, atom(), atom()) :: boolean() def can?(nil, _action, _resource), do: false def can?(user, action, resource) when is_atom(action) and is_atom(resource) do - resource_name = resource_name(resource) - - # Check cache first - case get_permission_from_cache(user.id, resource_name, action) do - {:ok, result} -> result - :miss -> check_permission_from_db(user, resource_name, action) + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + resource_name = get_resource_name(resource) + + Enum.any?(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) + else + _ -> false end end - + @doc """ - Checks if actor can access a specific page path. + Checks if user has permission for an action on a specific record (struct). + + Applies scope checking: + - :own - record.id == user.id + - :linked - record.user_id == user.id (or traverses relationships) + - :all - always true ## Examples - # In navigation component - <%= if can_access_page?(@current_user, "/members") do %> - <.link navigate="/members">Members - <% end %> - """ - def can_access_page?(nil, _page_path), do: false - - def can_access_page?(user, page_path) do - # Check cache first - case PermissionCache.get_page_permission(user.id, page_path) do - {:ok, result} -> result - :miss -> check_page_permission_from_db(user, page_path) - end - end - - @doc """ - Checks if actor can perform action on a specific record. - - This respects scope restrictions (own, linked, all). - - ## Examples - - # Show edit button only if user can edit THIS member - <%= if can?(@current_user, :update, member) do %> - - <% end %> + iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}} + iex> member = %Member{id: "member-456", user_id: "user-123"} + iex> can?(user, :update, member) + true + + iex> other_member = %Member{id: "member-789", user_id: "other-user"} + iex> can?(user, :update, other_member) + false """ + @spec can?(map() | nil, atom(), struct()) :: boolean() def can?(nil, _action, _record), do: false def can?(user, action, %resource{} = record) when is_atom(action) do - resource_name = resource_name(resource) - - # First check if user has any permission for this action - case get_permission_from_cache(user.id, resource_name, action) do - {:ok, false} -> - false + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + resource_name = get_resource_name(resource) - {:ok, true} -> - # User has permission, now check scope - check_scope_for_record(user, action, resource, record) + # Find matching permission + matching_perm = Enum.find(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) - :miss -> - check_permission_and_scope_from_db(user, action, resource, record) + case matching_perm do + nil -> false + perm -> check_scope(perm.scope, user, record, resource_name) + end + else + _ -> false end end + + @doc """ + Checks if user can access a specific page. - # Private helpers + ## Examples - defp resource_name(Mv.Accounts.User), do: "User" - defp resource_name(Mv.Membership.Member), do: "Member" - defp resource_name(Mv.Membership.Property), do: "Property" - defp resource_name(Mv.Membership.PropertyType), do: "PropertyType" - - defp get_permission_from_cache(user_id, resource_name, action) do - # Try to get from cache - # Returns {:ok, true}, {:ok, false}, or :miss - case PermissionCache.get_permission_set(user_id) do - {:ok, permission_set} -> - # Check if this permission set has the permission - has_permission = - permission_set.resources - |> Enum.any?(fn p -> - p.resource_name == resource_name and - p.action == to_string(action) and - p.granted == true - end) - - {:ok, has_permission} + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can_access_page?(admin, "/admin/roles") + true - :miss -> - :miss + iex> mitglied = %{role: %{permission_set_name: "own_data"}} + iex> can_access_page?(mitglied, "/members") + false + """ + @spec can_access_page?(map() | nil, String.t()) :: boolean() + def can_access_page?(nil, _page_path), do: false + + def can_access_page?(user, page_path) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + page_matches?(permissions.pages, page_path) + else + _ -> false end end + + # Check if scope allows access to record + defp check_scope(:all, _user, _record, _resource_name), do: true - defp check_permission_from_db(user, resource_name, action) do - # Load user's role and permission set - user = Ash.load!(user, role: [permission_set: :resources]) - - has_permission = - user.role.permission_set.resources - |> Enum.any?(fn p -> - p.resource_name == resource_name and - p.action == to_string(action) and - p.granted == true - end) - - # Cache the entire permission set - PermissionCache.put_permission_set(user.id, user.role.permission_set) - - has_permission + defp check_scope(:own, user, record, _resource_name) do + record.id == user.id end - defp check_page_permission_from_db(user, page_path) do - user = Ash.load!(user, role: [permission_set: :pages]) - - has_access = - user.role.permission_set.pages - |> Enum.any?(fn p -> p.page_path == page_path end) - - # Cache this specific page permission - PermissionCache.put_page_permission(user.id, page_path, has_access) - - has_access - end - - defp check_scope_for_record(user, action, resource, record) do - # Load the permission to check scope - user = Ash.load!(user, role: [permission_set: :resources]) - resource_name = resource_name(resource) - - permission = - user.role.permission_set.resources - |> Enum.find(fn p -> - p.resource_name == resource_name and - p.action == to_string(action) and - p.granted == true - end) - - case permission do - nil -> - false + defp check_scope(:linked, user, record, resource_name) do + case resource_name do + "Member" -> + # Direct relationship: member.user_id + Map.get(record, :user_id) == user.id - %{scope: "all"} -> - true - - %{scope: "own"} when resource == Mv.Accounts.User -> - # Check if record.id == user.id - record.id == user.id - - %{scope: "linked"} when resource == Mv.Membership.Member -> - # Check if record.user_id == user.id - record_with_user = Ash.load!(record, :user) - case record_with_user.user do - nil -> false - %{id: user_id} -> user_id == user.id - end - - %{scope: "linked"} when resource == Mv.Membership.Property -> - # Check if record.member.user_id == user.id - record_with_member = Ash.load!(record, member: :user) - case record_with_member.member do - nil -> false - %{user: nil} -> false - %{user: %{id: user_id}} -> user_id == user.id + "Property" -> + # Need to traverse: property.member.user_id + # Note: In UI, property should have member preloaded + case Map.get(record, :member) do + %{user_id: member_user_id} -> member_user_id == user.id + _ -> false end _ -> - false + # Fallback: check user_id + Map.get(record, :user_id) == user.id end end - - defp check_permission_and_scope_from_db(user, action, resource, record) do - case check_permission_from_db(user, resource_name(resource), action) do - false -> false - true -> check_scope_for_record(user, action, resource, record) + + # Check if page path matches any allowed pattern + defp page_matches?(allowed_pages, requested_path) do + Enum.any?(allowed_pages, fn pattern -> + cond do + pattern == "*" -> true + pattern == requested_path -> true + String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path) + true -> false + end + end) + end + + # Match dynamic route pattern + defp match_pattern?(pattern, path) do + pattern_segments = String.split(pattern, "/", trim: true) + path_segments = String.split(path, "/", trim: true) + + if length(pattern_segments) == length(path_segments) do + Enum.zip(pattern_segments, path_segments) + |> Enum.all?(fn {pattern_seg, path_seg} -> + String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg + end) + else + false end end + + # Extract resource name from module + defp get_resource_name(resource) when is_atom(resource) do + resource |> Module.split() |> List.last() + end end ``` ---- +### Import in mv_web.ex -#### Usage in LiveView Templates +Make helpers available to all LiveViews: -**Navigation Component:** +```elixir +defmodule MvWeb do + # ... + + def html_helpers do + quote do + # ... existing helpers ... + + # Authorization helpers + import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] + end + end + + # ... +end +``` + +### UI Examples + +**Navbar with conditional links:** ```heex @@ -1486,36 +1558,35 @@ end <.link navigate="/">Home - + <%= if can_access_page?(@current_user, "/members") do %> <.link navigate="/members">Members <% end %> - - <%= if can_access_page?(@current_user, "/users") do %> - <.link navigate="/users">Users - <% end %> - - - <%= if can_access_page?(@current_user, "/property-types") do %> - <.link navigate="/property-types">Custom Fields - <% end %> - - + <%= if can_access_page?(@current_user, "/admin/roles") do %> - <.link navigate="/admin/roles">Roles + <% end %> + + + <.link navigate="/profile">Profile ``` -**Index Page with Action Buttons:** +**Index page with conditional "New" button:** ```heex - + -<.table rows={@members}> - <:col :let={member} label="Name"> - <%= member.first_name %> <%= member.last_name %> - - - <:col :let={member} label="Email"> - <%= member.email %> - - - <:col :let={member} label="Actions"> - - <.link navigate={~p"/members/#{member}"} class="btn-secondary"> - Show - - - - <%= if can?(@current_user, :update, member) do %> - <.link patch={~p"/members/#{member}/edit"} class="btn-secondary"> - Edit - - <% end %> - - - <%= if can?(@current_user, :destroy, member) do %> - <.button phx-click="delete" phx-value-id={member.id} class="btn-danger"> - Delete - - <% end %> - - + + + <%= for member <- @members do %> + + + + + <% end %> +
<%= member.name %> + + <%= if can?(@current_user, :update, member) do %> + <.link patch={~p"/members/#{member.id}/edit"}>Edit + <% end %> + + + <%= if can?(@current_user, :destroy, member) do %> + <.button phx-click="delete" phx-value-id={member.id}>Delete + <% end %> +
``` -**Show Page:** +**Show page with conditional edit button:** ```heex - -