mitgliederverwaltung/docs/roles-and-permissions-architecture.md
Moritz 893f9453bd Add PermissionSets for Group, MemberGroup, MembershipFeeType, MembershipFeeCycle
- Extend permission_sets.ex with resources and pages for new domains
- Adjust HasPermission check for resource/action/scope
- Update roles-and-permissions and implementation-plan docs
- Add permission_sets_test.exs coverage
2026-02-03 23:52:09 +01:00

2882 lines
93 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Roles and Permissions Architecture - Technical Specification
**Version:** 2.0 (Clean Rewrite)
**Date:** 2025-01-13
**Last Updated:** 2026-01-13
**Status:** ✅ Implemented (2026-01-08, PR #346, closes #345)
**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)
- CustomFieldValue (custom field values)
- CustomField (custom field definitions)
- Role (role management)
- Group (group definitions; read all, create/update/destroy admin only)
- MemberGroup (membergroup associations; own_data read :linked, read_only read :all, normal_user/admin create/destroy)
- MembershipFeeType (fee type definitions; all read, admin-only create/update/destroy)
- MembershipFeeCycle (fee cycles; all read, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
**4. Page-Level Permissions**
Control access to LiveView pages:
- Index pages (list views)
- Show pages (detail views)
- Form pages (create/edit)
- Admin pages
- Settings pages: `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets)
**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: `id == user.member_id` (User.member_id Member.id, inverse relationship)
- CustomFieldValue: `member_id == user.member_id` (traverses Member User relationship)
- **: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
- **User Role Assignment:** Only admins can change a user's role (via `update_user` with `role_id`). Last-admin validation ensures at least one user keeps the Admin role.
- **Settings Pages:** `/settings` and `/membership_fee_settings` are admin-only (explicit in PermissionSets pages).
**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/custom field values
- 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 custom fields or users
4. **admin** - For "Admin" role
- Unrestricted access to all resources
- Can manage users, roles, custom fields
## 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},
# CustomFieldValue: Can read/update/create/destroy custom field values of linked member
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
# CustomField: Can read all (needed for forms)
%{resource: "CustomField", 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},
# CustomFieldValue: Can read all custom field values
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# CustomField: Can read all
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
"/members", # Member list
"/members/:id", # Member detail
"/custom_field_values" # Custom field values 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
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Read only (admin manages definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true}
],
pages: [
"/",
"/members",
"/members/new", # Create member
"/members/:id",
"/members/:id/edit", # Edit member
"/custom_field_values",
"/custom_field_values/new",
"/custom_field_values/: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},
# CustomFieldValue: Full CRUD
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Full CRUD (admin manages custom field definitions)
%{resource: "CustomField", action: :read, scope: :all, granted: true},
%{resource: "CustomField", action: :create, scope: :all, granted: true},
%{resource: "CustomField", action: :update, scope: :all, granted: true},
%{resource: "CustomField", 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 |
| **CustomFieldValue** (linked) | R, U, C, D | - | - | - |
| **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D |
| **CustomField** (all) | R | R | R | R, C, U, D |
| **Role** (all) | - | - | - | R, C, U, D |
| **Group** (all) | R | R | R | R, C, U, D |
| **MemberGroup** (linked) | R | - | - | - |
| **MemberGroup** (all) | - | R | R, C, D | R, C, D |
| **MembershipFeeType** (all) | R | R | R | R, C, U, D |
| **MembershipFeeCycle** (all) | R | R | R, C, U, D | 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: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
## 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" ->
# User.member_id → Member.id (inverse relationship)
# Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" ->
# CustomFieldValue.member_id → Member.id → User.member_id
# Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member_id == ^actor.member_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:** CustomFieldValue 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.
---
## Bypass vs. HasPermission: When to Use Which?
**Key Finding:** For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier approach**:
1. **Bypass with `expr()` for READ** - Handles list queries (auto_filter)
2. **HasPermission for UPDATE/CREATE/DESTROY** - Handles operations with records
### Why This Pattern?
**The Problem with HasPermission for List Queries:**
When `HasPermission` returns `{:filter, expr(...)}` for `scope :own` or `scope :linked`:
- `strict_check` returns `{:ok, false}` for queries without a record
- Ash does **NOT** reliably call `auto_filter` when `strict_check` returns `false`
- Result: List queries fail
**The Solution:**
Use `bypass` with `expr()` directly for READ operations:
- Ash handles `expr()` natively for both `strict_check` and `auto_filter`
- List queries work correctly
- Single-record reads work correctly
### Pattern Summary
| Operation | Has Record? | Use | Why |
|-----------|-------------|-----|-----|
| **READ (list)** | No | `bypass` with `expr()` | Triggers auto_filter |
| **READ (single)** | Yes | `bypass` with `expr()` | expr() evaluates to true/false |
| **UPDATE** | Yes (changeset) | `HasPermission` | strict_check can evaluate record |
| **CREATE** | Yes (changeset) | `HasPermission` | strict_check can evaluate record |
| **DESTROY** | Yes | `HasPermission` | strict_check can evaluate record |
### Is scope :own/:linked Still Useful?
**YES! ✅** The scope concept is essential:
1. **Documentation** - Clearly expresses intent in PermissionSets
2. **UPDATE/CREATE/DESTROY** - Works perfectly via HasPermission when record is present
3. **Consistency** - All permissions are centralized in PermissionSets
4. **Maintainability** - Easy to see what each role can do
The bypass is a **technical workaround** for Ash's auto_filter limitation, not a replacement for the scope concept.
### Consistency Across Resources
Both `User` and `Member` follow this pattern:
- **User**: Bypass for READ (`id == ^actor(:id)`), HasPermission for UPDATE (`scope :own`)
- **Member**: Bypass for READ (`id == ^actor(:member_id)`), HasPermission for UPDATE (`scope :linked`)
This ensures consistent behavior and predictable authorization logic throughout the application.
---
### User Resource Policies
**Location:** `lib/accounts/user.ex`
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own).
**Key Insight:** Bypass with `expr()` is needed ONLY for READ list queries because HasPermission's strict_check cannot properly trigger auto_filter. UPDATE operations work correctly via HasPermission because a changeset with record is available.
```elixir
defmodule Mv.Accounts.User do
use Ash.Resource, ...
policies do
# 1. AshAuthentication Bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
# 2. SPECIAL CASE: Users can always READ their own account
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
# UPDATE is handled by HasPermission below (scope :own works with changesets)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# 3. GENERAL: Check permissions from user's role
# - :own_data → can UPDATE own user (scope :own via HasPermission)
# - :read_only → can UPDATE own user (scope :own via HasPermission)
# - :normal_user → can UPDATE own user (scope :own via HasPermission)
# - :admin → can read/create/update/destroy all users (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# ...
end
```
**Why Bypass for READ but not UPDATE?**
- **READ list queries** (`Ash.read(User, actor: user)`): No record at strict_check time HasPermission returns `{:ok, false}` auto_filter not called bypass with `expr()` needed
- **UPDATE operations** (`Ash.update(changeset, actor: user)`): Changeset contains record HasPermission can evaluate `scope :own` correctly works via HasPermission
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read own | (bypass) | (bypass) | (bypass) | (bypass) | (scope :all) |
| Update own | (scope :own) | (scope :own) | (scope :own) | (scope :own) | (scope :all) |
| Read others | | | | | (scope :all) |
| Update others | | | | | (scope :all) |
| Create | | | | | (scope :all) |
| Destroy | | | | | (scope :all) |
**Note:** This pattern is consistent with Member resource policies (bypass for READ, HasPermission for UPDATE).
### Member Resource Policies
**Location:** `lib/mv/membership/member.ex`
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked).
**Key Insight:** Same pattern as User - bypass with `expr()` is needed ONLY for READ list queries. UPDATE operations work correctly via HasPermission because a changeset with record is available.
```elixir
defmodule Mv.Membership.Member do
use Ash.Resource, ...
policies do
# 1. SPECIAL CASE: Users can always READ their linked member
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
# UPDATE is handled by HasPermission below (scope :linked works with changesets)
bypass action_type(:read) do
description "Users can always read member linked to their account"
authorize_if expr(id == ^actor(:member_id))
end
# 2. GENERAL: Check permissions from role
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
# - :read_only → can READ all members (scope :all), no update permission
# - :normal_user → can CRUD all members (scope :all)
# - :admin → can CRUD all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
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
```
**Why Bypass for READ but not UPDATE?**
- **READ list queries**: No record at strict_check time bypass with `expr(id == ^actor(:member_id))` needed for auto_filter
- **UPDATE operations**: Changeset contains record HasPermission evaluates `scope :linked` correctly
**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)
### CustomFieldValue Resource Policies
**Location:** `lib/membership/custom_field_value.ex`
**Pattern:** Bypass for READ (list queries), CustomFieldValueCreateScope for create (no filter), HasPermission for read/update/destroy. Create uses a dedicated check because Ash cannot apply filters to create actions.
The bypass `action_type(:read)` is a production-side rule: reading own CFVs (where `member_id == actor.member_id`) is always allowed and overrides Permission-Sets; no further policies are needed for that. It applies to all read actions (get, list, load).
```elixir
defmodule Mv.Membership.CustomFieldValue do
use Ash.Resource, ...
policies do
# Bypass for READ (list queries; expr triggers auto_filter)
bypass action_type(:read) do
description "Users can read custom field values of their linked member"
authorize_if expr(member_id == ^actor(:member_id))
end
# CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create)
# own_data -> create when member_id == actor.member_id; normal_user/admin -> create (scope :all)
policy action_type(:create) do
authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope
end
# READ/UPDATE/DESTROY: HasPermission (scope :linked / :all)
policy action_type([:read, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed)
end
end
```
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read linked | (bypass) | (if linked) | | (if linked) | |
| Update linked | (scope :linked) | | | | |
| Create linked | (CustomFieldValueCreateScope) | | | | |
| Destroy linked | (scope :linked) | | | | |
| Read all | | | | | |
| Create all | | | | | |
| Destroy all | | | | | |
### CustomField Resource Policies
**Location:** `lib/membership/custom_field.ex`
**No Special Cases:** All users can read, only admin can write.
```elixir
defmodule Mv.Membership.CustomField do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
policies do
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
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`
### User Role Assignment (Admin-Only)
**Location:** `lib/accounts/user.ex` (update_user action), `lib/mv_web/live/user_live/form.ex`
Only admins can change a user's role. The `update_user` action accepts `role_id`; the User form shows a role dropdown when `can?(actor, :update, Mv.Authorization.Role)`. **Last-admin validation:** If the only non-system admin tries to change their role, the change is rejected with "At least one user must keep the Admin role." (System user is excluded from the admin count.) See [User-Member Linking](#user-member-linking) for the same admin-only pattern.
### Group Resource Policies
**Location:** `lib/membership/group.ex`
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
### MemberGroup Resource Policies
**Location:** `lib/membership/member_group.ex`
Bypass for read with `expr(member_id == ^actor(:member_id))` (own_data list); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). HasPermission applies `:linked` scope for MemberGroup (see HasPermission apply_scope).
### MembershipFeeType Resource Policies
**Location:** `lib/membership_fees/membership_fee_type.ex`
Policies use `HasPermission` for read/create/update/destroy. All permission sets can read; only admin can create, update, destroy.
### MembershipFeeCycle Resource Policies
**Location:** `lib/membership_fees/membership_fee_cycle.ex`
Policies use `HasPermission` for read/create/update/destroy. All can read; read_only cannot update/create/destroy; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles" in the member detail view; UI button is shown when `can_create_cycle`).
---
## 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`, `/custom_field_values`, `/profile`
- Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles`
**Kassenwart (normal_user):**
- Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/profile`
- Cannot access: `/admin/roles`, `/admin/custom_fields/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
"CustomFieldValue" ->
# Need to traverse: custom_field_value.member.user_id
# Note: In UI, custom_field_value 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
<!-- Note: Navbar has been replaced with Sidebar (lib/mv_web/components/layouts/sidebar.ex) -->
<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/custom_fields">Custom Fields</.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 uses a two-tier approach:
- **READ**: Bypass with `expr()` for list queries (auto_filter)
- **UPDATE**: HasPermission with `scope :own` (evaluates PermissionSets)
```elixir
policies do
# SPECIAL CASE: Users can always READ their own account
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# GENERAL: Check permissions from user's role
# UPDATE uses scope :own from PermissionSets (all sets grant User.update :own)
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
```
**Why this works:**
- READ bypass handles list queries correctly (auto_filter)
- UPDATE is handled by HasPermission with `scope :own` from PermissionSets
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own`
- Even a user with `read_only` (read-only for member data) can update their own credentials
**Important:** UPDATE is NOT an unverrückbarer Spezialfall (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details.
### 1a. User Credentials: Why read_only Can Still Update
**Question:** If `read_only` means "read-only", why can users with this permission set still update their own credentials?
**Answer:** The `read_only` permission set refers to **member data**, NOT user credentials. All permission sets grant `User.update :own` to allow password changes and profile updates.
**Implementation Details:**
1. **UPDATE is controlled by PermissionSets**, not a hardcoded bypass
2. **All 4 permission sets** (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant:
```elixir
%{resource: "User", action: :update, scope: :own, granted: true}
```
3. **HasPermission** evaluates `scope :own` for UPDATE operations (when a changeset with record is present)
4. **No special bypass** is needed for UPDATE - it works correctly via HasPermission
**Why This Design?**
- **Flexibility:** Permission sets can be modified to change UPDATE behavior
- **Consistency:** All permissions are centralized in PermissionSets
- **Clarity:** The name "read_only" refers to member data, not user credentials
- **Maintainability:** Easy to see what each role can do in PermissionSets module
**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional - UPDATE is controlled by PermissionSets, not hardcoded.
**Example:**
```elixir
# In PermissionSets.get_permissions(:read_only)
resources: [
# User: Can read/update own credentials only
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
# All permission sets grant User.update :own to allow password changes.
%{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},
# Note: No Member.update permission - this is the "read_only" part
]
```
### 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)
**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
### 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.CustomFieldValue` | "CustomFieldValue" |
| `Mv.Membership.CustomField` | "CustomField" |
| `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 -> CustomFieldValue)
- [ ] 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")
```
---
## Authorization Bootstrap Patterns
This section clarifies three different mechanisms for bypassing standard authorization, their purposes, and when to use each.
### Overview
The codebase uses two authorization bypass mechanisms:
1. **system_actor** - Admin user for systemic operations
2. **authorize?: false** - Bootstrap bypass for circular dependencies
**Both are necessary and serve different purposes.**
**Note:** The NoActor bypass has been removed to prevent masking authorization bugs in tests. All tests now explicitly use `system_actor` for authorization.
### 1. System Actor
**Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions.
**Implementation:**
```elixir
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# => %User{email: "system@mila.local", role: %{permission_set_name: "admin"}}
```
**Security:**
- No password (hashed_password = nil) → cannot login
- No OIDC ID (oidc_id = nil) → cannot authenticate
- Cached in Agent for performance
- Created automatically in test environment if missing
**Use Cases:**
- **Email synchronization** (User ↔ Member email sync)
- **Email uniqueness validation** (cross-resource checks)
- **Cycle generation** (mandatory side effect)
- **OIDC account linking** (user not yet logged in)
- **Cross-resource validations** (must work regardless of actor)
**Example:**
```elixir
def get_linked_member(%{member_id: id}) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Email sync must work regardless of user permissions
Ash.get(Mv.Membership.Member, id, opts)
end
```
**Why not `authorize?: false`?**
- System actor is explicit (clear intent: "systemic operation")
- Policies are evaluated (with admin rights)
- Audit trail (actor.email = "system@mila.local")
- Consistent authorization flow
- Testable
### 2. authorize?: false
**Purpose:** Skip policies for bootstrap scenarios with circular dependencies.
**Use Cases:**
**1. Seeds** - No admin exists yet to use as actor:
```elixir
# priv/repo/seeds.exs
Accounts.create_user!(%{email: admin_email},
authorize?: false # Bootstrap: no admin exists yet
)
```
**2. SystemActor Bootstrap** - Chicken-and-egg problem:
```elixir
# lib/mv/helpers/system_actor.ex
defp find_user_by_email(email) do
# Need to find system actor, but loading requires system actor!
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false) # Bootstrap only
end
```
**3. Actor.ensure_loaded** - Circular dependency:
```elixir
# lib/mv/authorization/actor.ex
defp load_role(actor) do
# Actor needs role for authorization,
# but loading role requires authorization!
Ash.load(actor, :role, authorize?: false) # Bootstrap only
end
```
**4. assign_default_role** - User creation:
```elixir
# User doesn't have actor during creation
Mv.Authorization.Role
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false) # Bootstrap only
```
**Security:**
- Very powerful - skips ALL policies
- Use sparingly and document every usage
- Only for bootstrap scenarios
- All current usages are legitimate
### Comparison
| Aspect | system_actor | authorize?: false |
|--------|--------------|-------------------|
| **Environment** | All | All |
| **Actor** | Admin user | nil |
| **Policies** | Evaluated | Skipped |
| **Audit Trail** | Yes (system@mila.local) | No |
| **Use Case** | Systemic operations, test fixtures | Bootstrap |
| **Explicit?** | Function call | Query option |
### Decision Guide
**Use system_actor when:**
- ✅ Systemic operation must always succeed
- ✅ Email synchronization
- ✅ Cycle generation
- ✅ Cross-resource validations
- ✅ OIDC flows (user not logged in)
**Use authorize?: false when:**
- ✅ Bootstrap scenario (seeds)
- ✅ Circular dependency (SystemActor bootstrap, Actor.ensure_loaded)
- ⚠️ Document with comment explaining why
**DON'T:**
- ❌ Use `authorize?: false` for user-initiated actions
- ❌ Use `authorize?: false` when `system_actor` would work
- ❌ Skip actor in tests (always use system_actor)
### The Circular Dependency Problem
**SystemActor Bootstrap:**
```
SystemActor.get_system_actor()
↓ calls find_user_by_email()
↓ needs to query User
↓ User policies require actor
↓ but we're loading the actor!
Solution: authorize?: false for bootstrap query
```
**Actor.ensure_loaded:**
```
Authorization check (HasPermission)
↓ needs actor.role.permission_set_name
↓ but role is %Ash.NotLoaded{}
↓ load role with Ash.load(actor, :role)
↓ but loading requires authorization
↓ which needs actor.role!
Solution: authorize?: false for role load
```
**Why this is safe:**
- Actor is loading their OWN data (role relationship)
- Actor already passed authentication boundary
- Role contains no sensitive data (just permission_set reference)
- Alternative (denormalize permission_set_name) adds complexity
### Examples
**Good - system_actor for systemic operation:**
```elixir
defp check_if_email_used(email) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Validation must work regardless of current actor
Ash.read(User, opts)
end
```
**Good - authorize?: false for bootstrap:**
```elixir
# Seeds - no admin exists yet
Accounts.create_user!(%{email: admin_email}, authorize?: false)
```
**Bad - authorize?: false for user action:**
```elixir
# WRONG: Bypasses all policies for user-initiated action
def delete_member(member) do
Ash.destroy(member, authorize?: false) # ❌ Don't do this!
end
# CORRECT: Use actor
def delete_member(member, actor) do
Ash.destroy(member, actor: actor) # ✅ Policies enforced
end
```
---
**Document Version:** 2.0 (Clean Rewrite)
**Last Updated:** 2026-01-23
**Implementation Status:** ✅ Complete (2026-01-08)
**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
**Changes from V2.0:**
- Added "Authorization Bootstrap Patterns" section explaining system_actor and authorize?: false
- Removed NoActor bypass (all tests now use system_actor for explicit authorization)
---
**End of Architecture Document**