2502 lines
75 KiB
Markdown
2502 lines
75 KiB
Markdown
# Roles and Permissions Architecture - Technical Specification
|
||
|
||
**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
|
||
|
||
- [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 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. **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
|
||
|
||
---
|
||
|
||
## Requirements Analysis
|
||
|
||
### Core Requirements
|
||
|
||
**1. Predefined Permission Sets**
|
||
|
||
Four hardcoded permission sets that define access patterns:
|
||
|
||
- **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
|
||
|
||
**2. Roles Stored in Database**
|
||
|
||
Five predefined roles stored in the `roles` table:
|
||
|
||
- **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
|
||
|
||
**3. Resource-Level Permissions**
|
||
|
||
Control CRUD operations on:
|
||
- User (credentials, profile)
|
||
- Member (member data)
|
||
- Property (custom field values)
|
||
- PropertyType (custom field definitions)
|
||
- Role (role management)
|
||
|
||
**4. Page-Level Permissions**
|
||
|
||
Control access to LiveView pages:
|
||
- Index pages (list views)
|
||
- Show pages (detail views)
|
||
- Form pages (create/edit)
|
||
- Admin pages
|
||
|
||
**5. Granular Scopes**
|
||
|
||
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
|
||
|
||
**6. Special Cases**
|
||
|
||
- **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
|
||
|
||
**7. UI Consistency**
|
||
|
||
- 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: Hardcoded Permission Sets with Database Roles
|
||
|
||
**Core Concept:**
|
||
|
||
```
|
||
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?**
|
||
|
||
✅ **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
|
||
|
||
**Trade-offs:**
|
||
|
||
⚠️ **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
|
||
|
||
### System Architecture Diagram
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Authorization System │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
|
||
┌──────────────────┐
|
||
│ 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 (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
|
||
|
||
```
|
||
┌─────────────────────────────────┐
|
||
│ 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
|
||
|
||
#### roles
|
||
|
||
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_name VARCHAR(50) NOT NULL,
|
||
is_system_role BOOLEAN NOT NULL DEFAULT false,
|
||
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'))
|
||
);
|
||
|
||
CREATE UNIQUE INDEX roles_name_index ON roles (name);
|
||
CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name);
|
||
```
|
||
|
||
**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")
|
||
|
||
**Constraints:**
|
||
- `name` must be unique
|
||
- `permission_set_name` must be one of 4 valid values
|
||
- System roles cannot be deleted (enforced in Ash resource)
|
||
|
||
#### users (modified)
|
||
|
||
Add foreign key to roles table.
|
||
|
||
```sql
|
||
ALTER TABLE users
|
||
ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT;
|
||
|
||
CREATE INDEX users_role_id_index ON users (role_id);
|
||
```
|
||
|
||
**ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it.
|
||
|
||
### Seed Data
|
||
|
||
Five predefined roles created during initial setup:
|
||
|
||
```elixir
|
||
# priv/repo/seeds/authorization_seeds.exs
|
||
|
||
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)
|
||
```
|
||
|
||
---
|
||
|
||
## Permission System Design (MVP)
|
||
|
||
### PermissionSets Module
|
||
|
||
**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
|
||
|
||
```elixir
|
||
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.
|
||
"""
|
||
|
||
@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()]
|
||
}
|
||
|
||
@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
|
||
```
|
||
|
||
#### Permission Matrix
|
||
|
||
Quick reference table showing what each permission set allows:
|
||
|
||
| 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 |
|
||
|
||
**Legend:** R=Read, C=Create, U=Update, D=Destroy
|
||
|
||
### 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.
|
||
|
||
```elixir
|
||
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
|
||
```
|
||
|
||
**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
|
||
|
||
---
|
||
|
||
## Resource Policies
|
||
|
||
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
|
||
# SPECIAL CASE: Users can always access their linked member
|
||
policy action_type([:read, :update]) do
|
||
description "Users can access member linked to their account"
|
||
authorize_if expr(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
|
||
|
||
# Custom validation for email editing (see Special Cases section)
|
||
validations do
|
||
validate changing(:email), on: :update do
|
||
validate &validate_linked_member_email_change/2
|
||
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 | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||
|
||
*Email editing has additional validation (see Special Cases)
|
||
|
||
### Property Resource Policies
|
||
|
||
**Location:** `lib/mv/membership/property.ex`
|
||
|
||
**Special Case:** Users can access properties of their linked member.
|
||
|
||
```elixir
|
||
defmodule Mv.Membership.Property do
|
||
use Ash.Resource, ...
|
||
|
||
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 System
|
||
|
||
Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug.
|
||
|
||
### CheckPagePermission 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.
|
||
|
||
## 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)
|
||
|
||
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
|
||
case conn.private[:phoenix_route] do
|
||
{_plug, _opts, _pipe, route_template, _meta} ->
|
||
route_template
|
||
|
||
_ ->
|
||
conn.request_path
|
||
end
|
||
end
|
||
|
||
# Check if user has permission for page
|
||
defp has_page_permission?(nil, _page_path) do
|
||
false
|
||
end
|
||
|
||
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
|
||
|
||
# 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
|
||
|
||
# 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)
|
||
|
||
# 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
|
||
|
||
Add plug to protected routes:
|
||
|
||
```elixir
|
||
defmodule MvWeb.Router do
|
||
use MvWeb, :router
|
||
|
||
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`)
|
||
|
||
---
|
||
|
||
## UI-Level 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 templates.
|
||
|
||
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
|
||
|
||
<!-- Conditional button rendering -->
|
||
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||
<.link patch={~p"/members/new"}>New Member</.link>
|
||
<% end %>
|
||
|
||
<!-- Record-level check -->
|
||
<%= if can?(@current_user, :update, @member) do %>
|
||
<.button>Edit</.button>
|
||
<% end %>
|
||
|
||
<!-- Page access check -->
|
||
<%= if can_access_page?(@current_user, "/admin/roles") do %>
|
||
<.link navigate="/admin/roles">Manage Roles</.link>
|
||
<% end %>
|
||
|
||
## Performance
|
||
|
||
All checks are pure function calls using the hardcoded PermissionSets module.
|
||
No database queries, < 1 microsecond per check.
|
||
"""
|
||
|
||
alias Mv.Authorization.PermissionSets
|
||
|
||
@doc """
|
||
Checks if user has permission for an action on a resource (atom).
|
||
|
||
## Examples
|
||
|
||
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||
iex> can?(admin, :create, Mv.Membership.Member)
|
||
true
|
||
|
||
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
|
||
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 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
|
||
|
||
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
|
||
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)
|
||
|
||
# Find matching permission
|
||
matching_perm = Enum.find(permissions.resources, fn perm ->
|
||
perm.resource == resource_name and
|
||
perm.action == action and
|
||
perm.granted
|
||
end)
|
||
|
||
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.
|
||
|
||
## Examples
|
||
|
||
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||
iex> can_access_page?(admin, "/admin/roles")
|
||
true
|
||
|
||
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_scope(:own, user, record, _resource_name) do
|
||
record.id == user.id
|
||
end
|
||
|
||
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
|
||
|
||
"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
|
||
|
||
_ ->
|
||
# Fallback: check user_id
|
||
Map.get(record, :user_id) == user.id
|
||
end
|
||
end
|
||
|
||
# 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
|
||
|
||
Make helpers available to all LiveViews:
|
||
|
||
```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
|
||
<!-- lib/mv_web/components/layouts/navbar.html.heex -->
|
||
<nav class="navbar">
|
||
<!-- Always visible -->
|
||
<.link navigate="/">Home</.link>
|
||
|
||
<!-- Members link (read or write access) -->
|
||
<%= if can_access_page?(@current_user, "/members") do %>
|
||
<.link navigate="/members">Members</.link>
|
||
<% end %>
|
||
|
||
<!-- Admin dropdown (admin only) -->
|
||
<%= if can_access_page?(@current_user, "/admin/roles") do %>
|
||
<div class="dropdown">
|
||
<span>Admin</span>
|
||
<ul>
|
||
<li><.link navigate="/admin/roles">Roles</.link></li>
|
||
<li><.link navigate="/admin/property_types">Property Types</.link></li>
|
||
</ul>
|
||
</div>
|
||
<% end %>
|
||
|
||
<!-- Profile (always visible for authenticated users) -->
|
||
<.link navigate="/profile">Profile</.link>
|
||
</nav>
|
||
```
|
||
|
||
**Index page with conditional "New" button:**
|
||
|
||
```heex
|
||
<!-- lib/mv_web/live/member_live/index.html.heex -->
|
||
<div class="page-header">
|
||
<h1>Members</h1>
|
||
|
||
<!-- Only show if user can create members -->
|
||
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||
<.link patch={~p"/members/new"} class="btn-primary">
|
||
New Member
|
||
</.link>
|
||
<% end %>
|
||
</div>
|
||
|
||
<table>
|
||
<!-- ... -->
|
||
<%= for member <- @members do %>
|
||
<tr>
|
||
<td><%= member.name %></td>
|
||
<td>
|
||
<!-- Show edit button only if user can update THIS member -->
|
||
<%= if can?(@current_user, :update, member) do %>
|
||
<.link patch={~p"/members/#{member.id}/edit"}>Edit</.link>
|
||
<% end %>
|
||
|
||
<!-- Show delete button only if user can destroy THIS member -->
|
||
<%= if can?(@current_user, :destroy, member) do %>
|
||
<.button phx-click="delete" phx-value-id={member.id}>Delete</.button>
|
||
<% end %>
|
||
</td>
|
||
</tr>
|
||
<% end %>
|
||
</table>
|
||
```
|
||
|
||
**Show page with conditional edit button:**
|
||
|
||
```heex
|
||
<!-- lib/mv_web/live/member_live/show.html.heex -->
|
||
<div class="member-detail">
|
||
<h1><%= @member.name %></h1>
|
||
|
||
<dl>
|
||
<dt>Email</dt>
|
||
<dd><%= @member.email %></dd>
|
||
|
||
<dt>Address</dt>
|
||
<dd><%= @member.address %></dd>
|
||
</dl>
|
||
|
||
<!-- Edit button only if user can update -->
|
||
<%= if can?(@current_user, :update, @member) do %>
|
||
<.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary">
|
||
Edit Member
|
||
</.link>
|
||
<% end %>
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## Special Cases
|
||
|
||
### 1. Own Credentials Access
|
||
|
||
**Requirement:** Every user can ALWAYS read and update their own credentials (email, password), regardless of their role.
|
||
|
||
**Implementation:**
|
||
|
||
Policy in `User` resource places this check BEFORE the general `HasPermission` check:
|
||
|
||
```elixir
|
||
policies do
|
||
# SPECIAL CASE: Takes precedence over role permissions
|
||
policy action_type([:read, :update]) do
|
||
description "Users can always read and update their own account"
|
||
authorize_if expr(id == ^actor(:id))
|
||
end
|
||
|
||
# GENERAL: For other operations (e.g., admin reading other users)
|
||
policy action_type([:read, :create, :update, :destroy]) do
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
end
|
||
```
|
||
|
||
**Why this works:**
|
||
- Ash evaluates policies top-to-bottom
|
||
- First matching policy wins
|
||
- Special case catches own-account access before checking permissions
|
||
- Even a user with `own_data` (no admin permissions) can update their credentials
|
||
|
||
### 2. Linked Member Email Editing
|
||
|
||
**Requirement:** Only administrators can edit the email of a member that is linked to a user (has `user_id` set). This prevents breaking email synchronization.
|
||
|
||
**Implementation:**
|
||
|
||
Custom validation in `Member` resource:
|
||
|
||
```elixir
|
||
defmodule Mv.Membership.Member do
|
||
use Ash.Resource, ...
|
||
|
||
validations do
|
||
# Only run when email is being changed
|
||
validate changing(:email), on: :update do
|
||
validate &validate_linked_member_email_change/2
|
||
end
|
||
end
|
||
|
||
defp validate_linked_member_email_change(changeset, _context) do
|
||
member = changeset.data
|
||
actor = changeset.context[:actor]
|
||
|
||
# If member is not linked to user, allow change
|
||
if is_nil(member.user_id) do
|
||
:ok
|
||
else
|
||
# Member is linked - check if actor is admin
|
||
if has_admin_permission?(actor) do
|
||
:ok
|
||
else
|
||
{:error, "Only administrators can change email for members linked to user accounts"}
|
||
end
|
||
end
|
||
end
|
||
|
||
defp has_admin_permission?(nil), do: false
|
||
|
||
defp has_admin_permission?(actor) do
|
||
with %{role: %{permission_set_name: ps_name}} <- actor,
|
||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||
# Check if actor has User.update permission with scope :all (admin privilege)
|
||
Enum.any?(permissions.resources, fn perm ->
|
||
perm.resource == "User" and
|
||
perm.action == :update and
|
||
perm.scope == :all and
|
||
perm.granted
|
||
end)
|
||
else
|
||
_ -> false
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
**Why this is needed:**
|
||
- Member email and User email are kept in sync
|
||
- If a non-admin changes linked member email, it could create inconsistency
|
||
- Validation runs AFTER policy check, so normal_user can update member
|
||
- But validation blocks email field specifically if member is linked
|
||
|
||
### 3. System Role Protection
|
||
|
||
**Requirement:** The "Mitglied" role cannot be deleted because it's the default role for all users.
|
||
|
||
**Implementation:**
|
||
|
||
Flag + validation in `Role` resource:
|
||
|
||
```elixir
|
||
defmodule Mv.Authorization.Role do
|
||
use Ash.Resource, ...
|
||
|
||
attributes do
|
||
# ...
|
||
attribute :is_system_role, :boolean, default: false
|
||
end
|
||
|
||
validations do
|
||
validate action(:destroy) do
|
||
validate fn _changeset, %{data: role} ->
|
||
if role.is_system_role do
|
||
{:error, "Cannot delete system role. System roles are required for the application to function."}
|
||
else
|
||
:ok
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
**Seeds set the flag:**
|
||
|
||
```elixir
|
||
%{
|
||
name: "Mitglied",
|
||
permission_set_name: "own_data",
|
||
is_system_role: true # <-- Protected!
|
||
}
|
||
```
|
||
|
||
**UI hides delete button:**
|
||
|
||
```heex
|
||
<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %>
|
||
<.button phx-click="delete">Delete</.button>
|
||
<% end %>
|
||
```
|
||
|
||
### 4. User Without Role (Edge Case)
|
||
|
||
**Requirement:** Users without a role should be denied all access (except logout).
|
||
|
||
**Implementation:**
|
||
|
||
**Default Assignment:** Seeds assign "Mitglied" role to all existing users
|
||
|
||
```elixir
|
||
# In authorization_seeds.exs
|
||
mitglied_role = Ash.get!(Role, name: "Mitglied")
|
||
users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))
|
||
|
||
Enum.each(users_without_role, fn user ->
|
||
Ash.update!(user, %{role_id: mitglied_role.id})
|
||
end)
|
||
```
|
||
|
||
**Runtime Handling:** All authorization checks handle missing role gracefully
|
||
|
||
```elixir
|
||
# In HasPermission check
|
||
def match?(actor, %{resource: resource, action: action}, _opts) do
|
||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
||
# ...
|
||
else
|
||
%{role: nil} ->
|
||
{:error, :no_role} # User has no role -> forbidden
|
||
|
||
_ ->
|
||
{:error, :no_permission}
|
||
end
|
||
end
|
||
```
|
||
|
||
**Result:** User with no role sees empty UI, cannot access pages, gets forbidden on all actions.
|
||
|
||
### 5. Invalid permission_set_name (Edge Case)
|
||
|
||
**Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing.
|
||
|
||
**Implementation:**
|
||
|
||
**Prevention:** Validation on Role resource
|
||
|
||
```elixir
|
||
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
|
||
```
|
||
|
||
**Runtime Handling:** All lookups check validity
|
||
|
||
```elixir
|
||
# In PermissionSets module
|
||
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
|
||
```
|
||
|
||
**Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default).
|
||
|
||
---
|
||
|
||
## User-Member Linking
|
||
|
||
### Requirement
|
||
|
||
Users and Members are separate entities that can be linked. Special rules:
|
||
- Only admins can link/unlink users and members
|
||
- A user cannot link themselves to an existing member
|
||
- A user CAN create a new member and be directly linked to it (self-service)
|
||
|
||
### Approach: Separate Ash Actions
|
||
|
||
We use **different Ash actions** to enforce different policies:
|
||
|
||
1. **`create_member_for_self`** - User creates member and links to themselves
|
||
2. **`create_member`** - Admin creates member for any user (or unlinked)
|
||
3. **`link_member_to_user`** - Admin links existing member to user
|
||
4. **`unlink_member_from_user`** - Admin removes user link
|
||
5. **`update`** - Standard update (cannot change `user_id`)
|
||
|
||
### Implementation
|
||
|
||
```elixir
|
||
defmodule Mv.Membership.Member do
|
||
use Ash.Resource, ...
|
||
|
||
actions do
|
||
# SELF-SERVICE: User creates member and links to self
|
||
create :create_member_for_self do
|
||
description "User creates a new member and links it to their own account"
|
||
|
||
accept [:name, :email, :address, ...] # All fields except user_id
|
||
|
||
# Automatically set user_id to actor
|
||
change set_attribute(:user_id, actor(:id))
|
||
|
||
# Prevent creating multiple members for same user (optional business rule)
|
||
validate fn changeset, _context ->
|
||
actor_id = get_change(changeset, :user_id)
|
||
|
||
case Ash.read(Member, filter: expr(user_id == ^actor_id)) do
|
||
{:ok, []} -> :ok # No existing member, allow
|
||
{:ok, [_member | _]} -> {:error, "You already have a member profile"}
|
||
{:error, _} -> :ok
|
||
end
|
||
end
|
||
end
|
||
|
||
# ADMIN: Create member with optional user link
|
||
create :create_member do
|
||
description "Admin creates a new member, optionally linked to a user"
|
||
|
||
accept [:name, :email, :address, ..., :user_id] # Admin can set user_id
|
||
end
|
||
|
||
# ADMIN: Link existing member to user
|
||
update :link_member_to_user do
|
||
description "Admin links an existing member to a user account"
|
||
|
||
accept [:user_id]
|
||
|
||
validate fn changeset, _context ->
|
||
member = changeset.data
|
||
|
||
# Cannot link if already linked
|
||
if is_nil(member.user_id) do
|
||
:ok
|
||
else
|
||
{:error, "Member is already linked to a user"}
|
||
end
|
||
end
|
||
end
|
||
|
||
# ADMIN: Remove user link from member
|
||
update :unlink_member_from_user do
|
||
description "Admin removes user link from member"
|
||
|
||
change set_attribute(:user_id, nil)
|
||
end
|
||
|
||
# STANDARD UPDATE: Cannot change user_id
|
||
update :update do
|
||
description "Update member data (cannot change user link)"
|
||
|
||
accept [:name, :email, :address, ...] # user_id NOT in accept list
|
||
end
|
||
end
|
||
|
||
policies do
|
||
# Self-service member creation
|
||
policy action(:create_member_for_self) do
|
||
description "Any authenticated user can create member for themselves"
|
||
authorize_if actor_present()
|
||
end
|
||
|
||
# Admin-only actions
|
||
policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do
|
||
description "Only admin can manage user-member links"
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
|
||
# Standard actions (regular permission check)
|
||
policy action([:read, :update, :destroy]) do
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
### UI Examples
|
||
|
||
**User Self-Service:**
|
||
|
||
```heex
|
||
<!-- User sees "Create My Profile" if they have no linked member -->
|
||
<%= if is_nil(@current_user.member_id) do %>
|
||
<.link navigate="/members/new_for_self">
|
||
Create My Member Profile
|
||
</.link>
|
||
<% end %>
|
||
|
||
<!-- Form for self-service member creation -->
|
||
<.simple_form for={@form} phx-submit="create_for_self">
|
||
<.input field={@form[:name]} label="Name" />
|
||
<.input field={@form[:email]} label="Email" />
|
||
<.input field={@form[:address]} label="Address" />
|
||
|
||
<!-- user_id is NOT in the form - automatically set by action -->
|
||
|
||
<:actions>
|
||
<.button>Create My Profile</.button>
|
||
</:actions>
|
||
</.simple_form>
|
||
```
|
||
|
||
**Admin Interface:**
|
||
|
||
```heex
|
||
<!-- Admin sees additional actions on member detail page -->
|
||
<%= if can?(@current_user, :link_member_to_user, @member) do %>
|
||
<%= if is_nil(@member.user_id) do %>
|
||
<!-- Link member to user -->
|
||
<.form for={@link_form} phx-submit="link_to_user">
|
||
<.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} />
|
||
<.button>Link to User</.button>
|
||
</.form>
|
||
<% else %>
|
||
<!-- Unlink member from user -->
|
||
<.button phx-click="unlink_from_user" phx-value-id={@member.id}>
|
||
Unlink from User (<%= @member.user.email %>)
|
||
</.button>
|
||
<% end %>
|
||
<% end %>
|
||
```
|
||
|
||
### Why Separate Actions?
|
||
|
||
✅ **Clear Intent:** Action name communicates what's happening
|
||
✅ **Precise Policies:** Different policies for different operations
|
||
✅ **Better UX:** Separate UI flows for self-service vs. admin
|
||
✅ **Testable:** Each action can be tested independently
|
||
✅ **Idiomatic Ash:** Uses Ash's action system as designed
|
||
|
||
---
|
||
|
||
## Future: Phase 2 - Field-Level Permissions
|
||
|
||
**Status:** Not in MVP, planned for future enhancement
|
||
|
||
**Goal:** Control which fields a user can read or write, beyond resource-level permissions.
|
||
|
||
### Strategy
|
||
|
||
**Extend PermissionSets module with `:fields` key:**
|
||
|
||
```elixir
|
||
def get_permissions(:read_only) do
|
||
%{
|
||
resources: [...],
|
||
pages: [...],
|
||
fields: [
|
||
# Vorstand can read all member fields except sensitive payment info
|
||
%{
|
||
resource: "Member",
|
||
action: :read,
|
||
fields: [:all],
|
||
excluded_fields: [:payment_method, :bank_account]
|
||
},
|
||
|
||
# Vorstand cannot write any member fields
|
||
%{
|
||
resource: "Member",
|
||
action: :update,
|
||
fields: [] # Empty = no fields writable
|
||
}
|
||
]
|
||
}
|
||
end
|
||
```
|
||
|
||
**Read Filtering via Ash Calculations:**
|
||
|
||
```elixir
|
||
defmodule Mv.Membership.Member do
|
||
calculations do
|
||
calculate :filtered_fields, :map do
|
||
calculate fn members, context ->
|
||
actor = context[:actor]
|
||
|
||
# Get allowed fields from PermissionSets
|
||
allowed_fields = get_allowed_read_fields(actor, "Member")
|
||
|
||
# Filter fields
|
||
Enum.map(members, fn member ->
|
||
Map.take(member, allowed_fields)
|
||
end)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
**Write Protection via Custom Validations:**
|
||
|
||
```elixir
|
||
validations do
|
||
validate on: :update do
|
||
validate fn changeset, context ->
|
||
actor = context[:actor]
|
||
changed_fields = Map.keys(changeset.attributes)
|
||
|
||
# Get allowed fields from PermissionSets
|
||
allowed_fields = get_allowed_write_fields(actor, "Member")
|
||
|
||
# Check if any forbidden field is being changed
|
||
forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields))
|
||
|
||
if Enum.empty?(forbidden) do
|
||
:ok
|
||
else
|
||
{:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"}
|
||
end
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
**Benefits:**
|
||
- ✅ No database schema changes
|
||
- ✅ Still uses hardcoded PermissionSets
|
||
- ✅ Granular control over sensitive fields
|
||
- ✅ Clear error messages
|
||
|
||
**Estimated Effort:** 2-3 weeks
|
||
|
||
---
|
||
|
||
## Future: Phase 3 - Database-Backed Permissions
|
||
|
||
**Status:** Not in MVP, planned for future when runtime configuration is needed
|
||
|
||
**Goal:** Move permission definitions from code to database for runtime configuration.
|
||
|
||
### High-Level Design
|
||
|
||
**New Tables:**
|
||
|
||
```sql
|
||
CREATE TABLE permission_sets (
|
||
id UUID PRIMARY KEY,
|
||
name VARCHAR(50) UNIQUE,
|
||
description TEXT,
|
||
is_system BOOLEAN
|
||
);
|
||
|
||
CREATE TABLE permission_set_resources (
|
||
id UUID PRIMARY KEY,
|
||
permission_set_id UUID REFERENCES permission_sets(id),
|
||
resource_name VARCHAR(100),
|
||
action VARCHAR(20),
|
||
scope VARCHAR(20),
|
||
granted BOOLEAN
|
||
);
|
||
|
||
CREATE TABLE permission_set_pages (
|
||
id UUID PRIMARY KEY,
|
||
permission_set_id UUID REFERENCES permission_sets(id),
|
||
page_pattern VARCHAR(255)
|
||
);
|
||
```
|
||
|
||
**Migration Strategy:**
|
||
|
||
1. Create new tables
|
||
2. Seed from current `PermissionSets` module
|
||
3. Create new `HasResourcePermission` check that queries DB
|
||
4. Add ETS cache for performance
|
||
5. Replace `HasPermission` with `HasResourcePermission` in policies
|
||
6. Test thoroughly
|
||
7. Deploy
|
||
8. Eventually remove `PermissionSets` module
|
||
|
||
**ETS Cache:**
|
||
|
||
```elixir
|
||
defmodule Mv.Authorization.PermissionCache do
|
||
def get_permissions(permission_set_id) do
|
||
case :ets.lookup(:permission_cache, permission_set_id) do
|
||
[{^permission_set_id, permissions}] ->
|
||
permissions
|
||
|
||
[] ->
|
||
permissions = load_from_db(permission_set_id)
|
||
:ets.insert(:permission_cache, {permission_set_id, permissions})
|
||
permissions
|
||
end
|
||
end
|
||
|
||
def invalidate(permission_set_id) do
|
||
:ets.delete(:permission_cache, permission_set_id)
|
||
end
|
||
end
|
||
```
|
||
|
||
**Benefits:**
|
||
- ✅ Runtime permission configuration
|
||
- ✅ More flexible than hardcoded
|
||
- ✅ Can add new permission sets without code changes
|
||
|
||
**Trade-offs:**
|
||
- ⚠️ More complex (DB queries, cache, invalidation)
|
||
- ⚠️ Slightly slower (mitigated by cache)
|
||
- ⚠️ More testing needed
|
||
|
||
**Estimated Effort:** 3-4 weeks
|
||
|
||
**Decision Point:** Migrate to Phase 3 only if:
|
||
- Need to add permission sets frequently
|
||
- Need per-tenant permission customization
|
||
- MVP hardcoded approach is limiting business
|
||
|
||
See [Migration Strategy](#migration-strategy) for detailed migration plan.
|
||
|
||
---
|
||
|
||
## Migration Strategy
|
||
|
||
### Three-Phase Approach
|
||
|
||
**Phase 1: MVP (2-3 weeks) - CURRENT**
|
||
- Hardcoded PermissionSets module
|
||
- `HasPermission` check reads from module
|
||
- Role table with `permission_set_name` string
|
||
- Zero DB queries for permission checks
|
||
|
||
**Phase 2: Field-Level (2-3 weeks) - FUTURE**
|
||
- Extend PermissionSets with `:fields` key
|
||
- Ash Calculations for read filtering
|
||
- Custom Validations for write protection
|
||
- No database schema changes
|
||
|
||
**Phase 3: Database-Backed (3-4 weeks) - FUTURE**
|
||
- New tables: `permission_sets`, `permission_set_resources`, `permission_set_pages`
|
||
- New `HasResourcePermission` check queries DB
|
||
- ETS cache for performance
|
||
- Runtime permission configuration
|
||
|
||
### When to Migrate?
|
||
|
||
**Stay with MVP if:**
|
||
- 4 permission sets are sufficient
|
||
- Permission changes are rare (quarterly or less)
|
||
- Code deployments for permission changes are acceptable
|
||
- Performance is critical (< 1μs checks)
|
||
|
||
**Migrate to Phase 2 if:**
|
||
- Need field-level granularity
|
||
- Different roles need access to different fields
|
||
- Still OK with hardcoded permissions
|
||
|
||
**Migrate to Phase 3 if:**
|
||
- Need frequent permission changes
|
||
- Need per-tenant customization
|
||
- Want non-technical users to configure permissions
|
||
- OK with slightly more complex system
|
||
|
||
### Migration from MVP to Phase 3
|
||
|
||
**Step-by-step:**
|
||
|
||
1. **Create DB Tables** (1 day)
|
||
- Run migrations for `permission_sets`, `permission_set_resources`, `permission_set_pages`
|
||
- Add indexes
|
||
|
||
2. **Seed from PermissionSets Module** (1 day)
|
||
- Script that reads from `PermissionSets.get_permissions/1`
|
||
- Inserts into new tables
|
||
- Verify data integrity
|
||
|
||
3. **Create HasResourcePermission Check** (2 days)
|
||
- New check that queries DB
|
||
- Same logic as `HasPermission` but different data source
|
||
- Comprehensive tests
|
||
|
||
4. **Implement ETS Cache** (2 days)
|
||
- Cache module
|
||
- Cache invalidation on updates
|
||
- Performance tests
|
||
|
||
5. **Update Policies** (3 days)
|
||
- Replace `HasPermission` with `HasResourcePermission` in all resources
|
||
- Test each resource thoroughly
|
||
|
||
6. **Update UI Helpers** (1 day)
|
||
- Modify `MvWeb.Authorization` to query DB
|
||
- Use cache for performance
|
||
|
||
7. **Update Page Plug** (1 day)
|
||
- Modify `CheckPagePermission` to query DB
|
||
- Use cache
|
||
|
||
8. **Integration Testing** (3 days)
|
||
- Full user journey tests
|
||
- Performance testing
|
||
- Load testing
|
||
|
||
9. **Deploy to Staging** (1 day)
|
||
- Feature flag approach
|
||
- Run both systems in parallel
|
||
- Compare results
|
||
|
||
10. **Deploy to Production** (1 day)
|
||
- Gradual rollout
|
||
- Monitor performance
|
||
- Rollback plan ready
|
||
|
||
11. **Cleanup** (1 day)
|
||
- Remove old `HasPermission` check
|
||
- Remove `PermissionSets` module
|
||
- Update documentation
|
||
|
||
**Total:** ~3-4 weeks
|
||
|
||
---
|
||
|
||
## Security Considerations
|
||
|
||
### Threat Model
|
||
|
||
**Threats Addressed:**
|
||
|
||
1. **Unauthorized Data Access:** Policies prevent users from accessing data outside their permissions
|
||
2. **Privilege Escalation:** Role-based system prevents users from granting themselves higher privileges
|
||
3. **UI Tampering:** Backend policies enforce authorization even if UI is bypassed
|
||
4. **Session Hijacking:** Mitigation handled by existing authentication system (not in scope)
|
||
|
||
**Threats NOT Addressed:**
|
||
|
||
1. **SQL Injection:** Ash Framework handles query building securely
|
||
2. **XSS:** Phoenix LiveView handles HTML escaping
|
||
3. **CSRF:** Phoenix CSRF tokens (existing)
|
||
|
||
### Defense in Depth
|
||
|
||
**Three Layers of Authorization:**
|
||
|
||
1. **Page Access Layer (Plug):**
|
||
- Blocks unauthorized page access
|
||
- Runs before LiveView mounts
|
||
- Fast fail for obvious violations
|
||
|
||
2. **UI Layer (Authorization Helpers):**
|
||
- Hides buttons/links user can't use
|
||
- Prevents confusing "forbidden" errors
|
||
- Improves UX
|
||
|
||
3. **Resource Layer (Ash Policies):**
|
||
- **Primary enforcement point**
|
||
- Cannot be bypassed
|
||
- Filters queries automatically
|
||
|
||
**Even if attacker:**
|
||
- Tampers with UI → Backend policies still enforce
|
||
- Calls API directly → Policies apply
|
||
- Modifies page JavaScript → Policies apply
|
||
|
||
### Authorization Best Practices
|
||
|
||
**DO:**
|
||
- ✅ Always preload `:role` relationship for actor
|
||
- ✅ Log authorization failures for debugging
|
||
- ✅ Use explicit policies (no implicit allow)
|
||
- ✅ Test policies with all role types
|
||
- ✅ Test special cases (nil role, invalid permission_set_name)
|
||
|
||
**DON'T:**
|
||
- ❌ Trust UI-level checks alone
|
||
- ❌ Skip policy checks for "admin"
|
||
- ❌ Use `bypass` or `skip_authorization` in production
|
||
- ❌ Expose raw permission logic in API responses
|
||
|
||
### Audit Logging (Future)
|
||
|
||
**Not in MVP, but planned:**
|
||
|
||
```elixir
|
||
defmodule Mv.Authorization.AuditLog do
|
||
def log_authorization_failure(actor, resource, action, reason) do
|
||
Ash.create!(AuditLog, %{
|
||
user_id: actor.id,
|
||
resource: inspect(resource),
|
||
action: action,
|
||
outcome: "forbidden",
|
||
reason: reason,
|
||
ip_address: get_ip_address(),
|
||
timestamp: DateTime.utc_now()
|
||
})
|
||
end
|
||
end
|
||
```
|
||
|
||
**Benefits:**
|
||
- Track suspicious authorization attempts
|
||
- Compliance (GDPR requires access logs)
|
||
- Debugging production issues
|
||
|
||
---
|
||
|
||
## Appendix
|
||
|
||
### Glossary
|
||
|
||
- **Permission Set:** Named collection of permissions (e.g., "admin", "read_only")
|
||
- **Role:** Database entity linking users to permission sets
|
||
- **Scope:** Range of records permission applies to (:own, :linked, :all)
|
||
- **Actor:** Currently authenticated user in Ash context
|
||
- **Policy:** Ash authorization rule on a resource
|
||
- **System Role:** Role that cannot be deleted (is_system_role=true)
|
||
- **Special Case:** Authorization rule that takes precedence over general permissions
|
||
|
||
### Resource Name Mapping
|
||
|
||
The `HasPermission` check extracts resource names via `Module.split() |> List.last()`:
|
||
|
||
| Ash Module | Resource Name (String) |
|
||
|------------|------------------------|
|
||
| `Mv.Accounts.User` | "User" |
|
||
| `Mv.Membership.Member` | "Member" |
|
||
| `Mv.Membership.Property` | "Property" |
|
||
| `Mv.Membership.PropertyType` | "PropertyType" |
|
||
| `Mv.Authorization.Role` | "Role" |
|
||
|
||
These strings must match exactly in `PermissionSets` module.
|
||
|
||
### Permission Set Summary
|
||
|
||
| Permission Set | Typical Roles | Key Characteristics |
|
||
|----------------|---------------|---------------------|
|
||
| **own_data** | Mitglied | Can only access own data and linked member |
|
||
| **read_only** | Vorstand, Buchhaltung | Read all data, no modifications |
|
||
| **normal_user** | Kassenwart | Create/Read/Update members (no delete), full CRUD on properties, no admin |
|
||
| **admin** | Admin | Unrestricted access, wildcard pages |
|
||
|
||
### Edge Case Reference
|
||
|
||
| Edge Case | Behavior | Implementation |
|
||
|-----------|----------|----------------|
|
||
| User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully |
|
||
| Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks |
|
||
| System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` |
|
||
| Linked member email | Admin-only edit | Custom validation in Member resource |
|
||
| Own credentials | Always accessible | Special policy before general check |
|
||
|
||
### Testing Checklist
|
||
|
||
**For Each Resource:**
|
||
- [ ] All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
|
||
- [ ] All actions tested (read, create, update, destroy)
|
||
- [ ] All scopes tested (own, linked, all)
|
||
- [ ] Special cases tested
|
||
- [ ] Edge cases tested (nil role, invalid permission_set_name)
|
||
|
||
**For UI:**
|
||
- [ ] Buttons/links show/hide correctly per role
|
||
- [ ] Page access controlled per role
|
||
- [ ] No broken links (all visible links are accessible)
|
||
|
||
**Integration:**
|
||
- [ ] One complete user journey per role
|
||
- [ ] Cross-resource scenarios (e.g., Member -> Property)
|
||
- [ ] Special cases in context (e.g., linked member email during full edit flow)
|
||
|
||
### Useful Commands
|
||
|
||
```bash
|
||
# Run all authorization tests
|
||
mix test test/mv/authorization
|
||
|
||
# Run integration tests
|
||
mix test test/integration
|
||
|
||
# Run with coverage
|
||
mix test --cover
|
||
|
||
# Generate migrations
|
||
mix ash.codegen
|
||
|
||
# Run seeds
|
||
mix run priv/repo/seeds/authorization_seeds.exs
|
||
|
||
# Check permission for user in IEx
|
||
iex> user = Mv.Accounts.get_user!("user-id")
|
||
iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member)
|
||
|
||
# Check page access in IEx
|
||
iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
|
||
```
|
||
|
||
---
|
||
|
||
**Document Version:** 2.0 (Clean Rewrite)
|
||
**Last Updated:** 2025-01-13
|
||
**Status:** Ready for Implementation
|
||
|
||
**Changes from V1:**
|
||
- Complete rewrite focused on MVP (hardcoded permissions)
|
||
- Removed all database-backed permission details from MVP sections
|
||
- Unified naming (HasPermission for MVP)
|
||
- Added Role resource policies
|
||
- Clarified resource-specific :linked scope
|
||
- Moved Phase 2 and Phase 3 to clearly marked "Future" sections
|
||
- Fixed Buchhaltung inconsistency (read_only everywhere)
|
||
- Added comprehensive security section
|
||
- Enhanced edge case documentation
|
||
|
||
---
|
||
|
||
**End of Architecture Document**
|
||
|