1640 lines
67 KiB
Markdown
1640 lines
67 KiB
Markdown
# 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 normal_user and admin)
|
||
- MemberGroup (member–group 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; own_data read :linked, read_only read :all, normal_user/admin read+create+update+destroy; manual "Regenerate Cycles" for normal_user and admin)
|
||
- JoinRequest (membership join requests; normal_user read+update, admin full CRUD)
|
||
|
||
**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 administrators or the linked user can change the email for members linked to users (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||
- **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 (2-3 weeks vs. 4-5 for DB-backed), maximum performance (< 1μs per
|
||
check, no permission queries/joins/cache), Git-tracked permission changes, deterministic
|
||
functional tests, and a well-defined Phase 3 migration path.
|
||
|
||
**Trade-offs:** Permission changes need a code deployment and new sets cannot be added without
|
||
a code change — acceptable for the MVP, which specifies 4 fixed sets with rare changes.
|
||
|
||
### 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. The SQL below is
|
||
**illustrative** — see `priv/repo/migrations/*_add_authorization_domain.exs` for the exact DDL
|
||
(notably the primary key uses the custom `uuid_generate_v7()` SQL function, and the unique index
|
||
is named `roles_unique_name_index`).
|
||
|
||
```sql
|
||
CREATE TABLE roles (
|
||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||
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_unique_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
|
||
|
||
The five predefined roles are seeded in `priv/repo/seeds_bootstrap.exs` (the canonical source —
|
||
do not duplicate the data here). Each role is created idempotently via the Role resource's
|
||
`:create_role_with_system_flag` action, and the seeds map roles to permission sets as:
|
||
|
||
| Role | permission_set_name | is_system_role |
|
||
|------|---------------------|----------------|
|
||
| Mitglied | own_data | true (cannot be deleted) |
|
||
| Vorstand | read_only | false |
|
||
| Kassenwart | normal_user | false |
|
||
| Buchhaltung | read_only | false |
|
||
| Admin | admin | false |
|
||
|
||
Assigning the default "Mitglied" role to users that have no role is handled separately by the
|
||
`assign_mitglied_role_to_existing_users` migration, not by the seed script.
|
||
|
||
---
|
||
|
||
## 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, as pure compile-time functions (lookups < 1μs).
|
||
|
||
**Types & public API:**
|
||
|
||
- `@type scope :: :own | :linked | :all`, `@type action :: :read | :create | :update | :destroy`
|
||
- A `resource_permission` is `%{resource: String.t(), action:, scope:, granted: boolean}`;
|
||
a `permission_set` is `%{resources: [resource_permission], pages: [String.t()]}`.
|
||
- `all_permission_sets/0` → `[:own_data, :read_only, :normal_user, :admin]`.
|
||
- `get_permissions/1` — one function clause per set returning its `%{resources, pages}` map.
|
||
An unknown atom raises `ArgumentError` (callers always go through the conversion below).
|
||
- `valid_permission_set?/1` — accepts string or atom; the string clause delegates to the
|
||
converter; the atom clause checks membership in `all_permission_sets/0`.
|
||
- `permission_set_name_to_atom/1` — `String.to_existing_atom/1` guarded by validity, and
|
||
**rescues `ArgumentError`** (unknown string → never-created atom) returning
|
||
`{:error, :invalid_permission_set}`. This is the safe entry point used everywhere.
|
||
|
||
**Resource permissions per set** are exactly the Permission Matrix below. Note `normal_user`
|
||
intentionally omits `Member :destroy` (safety); `own_data` has full CRUD on its linked
|
||
CustomFieldValues; all four sets grant `User read/update :own`.
|
||
|
||
**Pages per set:** the exact `pages` lists live in the `get_permissions/1` clauses of
|
||
`Mv.Authorization.PermissionSets` (single source of truth). Key facts that shape the lists:
|
||
|
||
- **own_data:** deliberately does **not** include `/` (Mitglied must not see the member index at
|
||
root, which has the same content as `/members`). Self-service pages are `/users/:id`,
|
||
`/users/:id/edit`, `/users/:id/show/edit`; linked-member pages are `/members/:id`,
|
||
`/members/:id/edit`, `/members/:id/show/edit` (data access filtered by policy scope `:linked`).
|
||
- **read_only / normal_user:** include `/` plus the self-service `/users/:id…` pages and their
|
||
respective member / custom-field-value / group pages; normal_user additionally has the create/edit
|
||
pages and the `/join_requests` approval pages.
|
||
- **admin:** `"*"` wildcard (all pages), with `/settings` and `/membership_fee_settings` also listed
|
||
explicitly.
|
||
|
||
There is no `/profile` route; the self-service profile pages are the `/users/:id…` routes above.
|
||
|
||
#### 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, C, U, D | 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** (linked) | R | - | - | - |
|
||
| **MembershipFeeCycle** (all) | - | R | R, C, U, D | R, C, U, D |
|
||
| **JoinRequest** (all) | - | - | R, U | 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
|
||
# ...
|
||
end
|
||
```
|
||
|
||
**`match?/3` logic.** With-chain: read `actor.role.permission_set_name` (non-nil) →
|
||
`PermissionSets.permission_set_name_to_atom/1` → `get_permissions/1` → resource name via
|
||
`Module.split() |> List.last()`. Find the granted permission matching resource+action; if none,
|
||
`{:error, :no_permission}`; otherwise apply the scope filter. The `else` clauses log and return a
|
||
specific reason — `{:error, :no_role}` (role nil), `{:error, :no_permission_set}`
|
||
(permission_set_name nil), `{:error, :invalid_permission_set}`, or `{:error, :no_permission}`
|
||
(no actor/missing data). Every error results in Forbidden (fail-closed).
|
||
|
||
**Scope filters (`apply_scope/3`):**
|
||
|
||
- `:all` → `:authorized` (no filter)
|
||
- `:own` → `{:filter, expr(id == ^actor.id)}` (User: own record)
|
||
- `:linked` → resource-specific:
|
||
- `"Member"` → `{:filter, expr(id == ^actor.member_id)}` (User.member_id → Member.id, inverse)
|
||
- `"CustomFieldValue"` → `{:filter, expr(member_id == ^actor.member_id)}` (traverses CFV.member_id → Member → User.member_id)
|
||
- fallback → `{:filter, expr(user_id == ^actor.id)}`
|
||
|
||
**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
|
||
|
||
For filter-based permissions (`scope :own`, `scope :linked`) the resources use a two-tier
|
||
pattern: **bypass with `expr()` for READ** (Ash does not reliably trigger `auto_filter`
|
||
when `HasPermission`'s `strict_check` returns `{:ok, false}` on record-less list queries),
|
||
and **HasPermission for UPDATE/CREATE/DESTROY** (a changeset record is present, so scope is
|
||
evaluated correctly). The scope concept stays meaningful — bypass is only a workaround for
|
||
Ash's auto_filter limitation, not a replacement for it.
|
||
|
||
The full rationale, the per-operation decision table, and why both `User` and `Member`
|
||
follow this pattern are documented in the canonical
|
||
[policy-bypass-vs-haspermission.md](./policy-bypass-vs-haspermission.md).
|
||
|
||
---
|
||
|
||
### 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/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. READ/DESTROY: Check permissions only (no :user argument on these actions)
|
||
policy action_type([:read, :destroy]) do
|
||
description "Check permissions from user's role"
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
|
||
# 3. CREATE/UPDATE: Forbid user link unless admin; then check permissions
|
||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all
|
||
policy action_type([:create, :update]) do
|
||
description "Forbid user link unless admin; then check permissions"
|
||
forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
end
|
||
|
||
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||
end
|
||
|
||
# Linked-member email editing is enforced by a dedicated Validations module
|
||
# (see Special Cases section)
|
||
validations do
|
||
validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]
|
||
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 ✅
|
||
|
||
**User–member link:** Only admins may pass the `:user` argument on create_member or update_member (link or unlink via `user: nil`/`user: %{}`). The check uses **argument presence** (key in arguments), not value, to avoid bypass (see [User-Member Linking](#user-member-linking)).
|
||
|
||
**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`
|
||
|
||
**Defense-in-depth:** The Role resource uses `authorizers: [Ash.Policy.Authorizer]` and policies with `Mv.Authorization.Checks.HasPermission`. **Read** is allowed for all permission sets (own_data, read_only, normal_user, admin) via `perm("Role", :read, :all)` in PermissionSets; reading roles is not a security concern. **Create, update, and destroy** are allowed only for admin (admin has full Role CRUD in PermissionSets). Seeds and bootstrap use `authorize?: false` where necessary.
|
||
|
||
**Special Protection:** System roles cannot be deleted (validation on destroy).
|
||
|
||
```elixir
|
||
defmodule Mv.Authorization.Role do
|
||
use Ash.Resource,
|
||
authorizers: [Ash.Policy.Authorizer]
|
||
|
||
policies do
|
||
policy action_type([:read, :create, :update, :destroy]) do
|
||
description "Check permissions from user's role (read all, create/update/destroy admin only)"
|
||
authorize_if Mv.Authorization.Checks.HasPermission
|
||
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; normal_user and admin can create, update, destroy. No bypass (scope :all only in PermissionSets).
|
||
|
||
### MemberGroup Resource Policies
|
||
|
||
**Location:** `lib/membership/member_group.ex`
|
||
|
||
Bypass for read restricted to own_data (MemberGroupReadLinkedForOwnData check: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/destroy (normal_user + admin only). Admin with member_id set still gets :all from HasPermission (bypass does not apply).
|
||
|
||
### 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`
|
||
|
||
Bypass for read restricted to own_data (MembershipFeeCycleReadLinkedForOwnData: own_data only, filter `member_id == actor.member_id`); HasPermission for read (read_only/normal_user/admin :all) and create/update/destroy. own_data can only read cycles of the linked member; read_only can read all; normal_user and admin can read, create, update, and destroy (including mark_as_paid and manual "Regenerate Cycles"; UI button when `can_create_cycle`). Regenerate-cycles handler enforces `can?(:create, MembershipFeeCycle)` server-side.
|
||
|
||
---
|
||
|
||
## 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, **before** the LiveView mounts.
|
||
|
||
**Behavior (`lib/mv_web/plugs/check_page_permission.ex`):**
|
||
|
||
1. Extracts the page path as the **route template** (e.g. `/members/:id`) via
|
||
`Phoenix.Router.route_info/4`, falling back to `conn.request_path`. Using the template,
|
||
not the concrete path, is what lets the permission `pages` lists stay parameterized.
|
||
(Public paths such as `/sign-in`, `/register`, `/auth/*` are exempt and pass through.)
|
||
2. Reads `current_user` from `conn.assigns`, resolves its `permission_set_name`, and looks up
|
||
the allowed `pages` via `PermissionSets.get_permissions/1`.
|
||
3. Matches the path against allowed patterns: `*` wildcard (admin), exact match, or
|
||
segment-wise dynamic match where a `:`-prefixed pattern segment matches any path segment
|
||
(same segment count required).
|
||
4. On no match (including nil user, no role, or invalid permission set → false): logs the
|
||
denial and **redirects to `/users/:id`** (the logged-in user's own profile) or, when there is
|
||
no user, to **`/sign-in`**, then halts. The `"You don't have permission to access this page."`
|
||
flash is set only for a logged-in user; an unauthenticated visitor is redirected without a flash.
|
||
|
||
### 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: `/users/123` (own profile), `/members/123` (if 123 is their linked member)
|
||
- ❌ Cannot access: `/` (root member index is excluded for own_data), `/members`, `/members/new`, `/settings`
|
||
|
||
**Vorstand (read_only):**
|
||
- ✅ Can access: `/`, `/members`, `/members/123`, `/custom_field_values`, `/users/123` (own profile)
|
||
- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/settings`
|
||
|
||
**Kassenwart (normal_user):**
|
||
- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/custom_field_values`, `/join_requests`, `/users/123` (own profile)
|
||
- ❌ Cannot access: `/settings`, `/membership_fee_settings`
|
||
|
||
**Admin:**
|
||
- ✅ Can access: `*` (all pages, including `/settings` and `/membership_fee_settings`)
|
||
|
||
---
|
||
|
||
## 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,
|
||
reading from the **same** `PermissionSets` module as the backend policies so UI and backend
|
||
stay consistent (pure function calls, no DB queries). Imported into `mv_web.ex` `html_helpers`
|
||
so every LiveView has it: `import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]`.
|
||
|
||
**Public functions:**
|
||
|
||
- `can?/3` (resource atom) — `can?(user, action, Mv.Membership.Member)`: true iff the user's
|
||
permission set grants `action` on that resource (any scope).
|
||
- `can?/3` (record struct) — `can?(user, action, %Member{})`: finds the matching permission,
|
||
then applies the scope check against the record:
|
||
- `:all` → always true
|
||
- `:own` → `record.id == user.id`
|
||
- `:linked` → resource-specific: Member checks `record.user_id == user.id`; CustomFieldValue
|
||
traverses `record.member.user_id == user.id` (member must be preloaded), with a `user_id`
|
||
fallback for other resources.
|
||
- `can_access_page?/2` — matches the path against the permission set's `pages` list using the
|
||
same rules as the plug: `*` wildcard, exact match, or dynamic segment match (`:id`).
|
||
|
||
All three return **false** for a nil user, a user without a role, or an invalid
|
||
`permission_set_name` (graceful, fail-closed — no crash). The scope/page-matching logic mirrors
|
||
`HasPermission` and `CheckPagePermission` exactly; resource names come from
|
||
`Module.split() |> List.last()`.
|
||
|
||
### UI Usage Pattern
|
||
|
||
LiveView templates gate elements with the helpers: page-level links use
|
||
`can_access_page?(@current_user, path)` (e.g. the `/members` link and the admin
|
||
dropdown), resource-level buttons use `can?(@current_user, :create, Resource)`
|
||
(e.g. "New Member"), and per-record buttons use `can?(@current_user, action, record)`
|
||
(e.g. Edit/Delete in a member row, or the edit button on a show page). The navbar has
|
||
since been replaced by the sidebar (`lib/mv_web/components/layouts/sidebar.ex`).
|
||
|
||
---
|
||
|
||
## 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 immovable special case (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. Every set's `get_permissions/...` therefore carries both `%{resource: "User", action: :read, scope: :own}` and `%{... action: :update, scope: :own}`; the "read_only" label applies to member data (no `Member :update`), not credentials.
|
||
|
||
### 2. Linked Member Email Editing
|
||
|
||
**Requirement:** For a member linked to a user account (has a linked user), only administrators **or the linked user themselves** can change the email. This prevents breaking the Member↔User email synchronization while still letting a user update their own email.
|
||
|
||
**Implementation:** The `Mv.Membership.Member.Validations.EmailChangePermission` module (registered as `validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]`) runs **after** the policy check (so a `normal_user` may update the member but is still blocked on the email field). It only acts when the email is changing: if the member has no linked user it allows the change; otherwise it allows the change when the actor is admin (`Mv.Authorization.Actor.admin?/1`, which also treats the system actor as admin) **or** owns the linked member (`actor.member_id == member.id`), and otherwise returns `{:error, "Only administrators or the linked user can change the email for members linked to users"}`. A missing actor is not allowed.
|
||
|
||
### 3. System Role Protection
|
||
|
||
**Requirement:** The "Mitglied" role cannot be deleted (it's the default role for all users).
|
||
|
||
**Implementation:** The `Role` resource has an `is_system_role` boolean (default false); a destroy validation returns `{:error, "Cannot delete system role. ..."}` when `role.is_system_role` is true. Seeds set `is_system_role: true` only on "Mitglied". The UI also hides the delete button: `can?(@current_user, :destroy, role) and not role.is_system_role`.
|
||
|
||
### 4. User Without Role (Edge Case)
|
||
|
||
**Requirement:** Users without a role are denied all access (except logout).
|
||
|
||
**Implementation:** Seeds assign "Mitglied" to all users where `role_id` is nil. At runtime every check handles a missing role gracefully — `HasPermission` returns `{:error, :no_role}` (and the UI helpers/plug return false) rather than crashing.
|
||
|
||
**Result:** A user with no role sees an empty UI, cannot access pages, and is 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:** Prevented up front by a `Role` attribute validation that rejects any value not in `PermissionSets.all_permission_sets/0` (`"Invalid permission set name. Must be one of: ..."`). At runtime, every lookup goes through `permission_set_name_to_atom/1`, which rescues the `ArgumentError` from `String.to_existing_atom/1` (see PermissionSets above), so an invalid name yields `{:error, :invalid_permission_set}`.
|
||
|
||
**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:**
|
||
|
||
- **User side:** 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.
|
||
- **Member side:** Only admins may set or change the user–member link on **Member** create or update. When creating or updating a member, the `:user` argument (which links the member to a user account) is forbidden for non-admins. This is enforced by `Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` in the Member resource policies (`forbid_if` before `authorize_if HasPermission`). Non-admins can still create and update members as long as they do **not** pass the `:user` argument. The Member resource uses **`on_missing: :ignore`** for the `:user` relationship on update_member, so **omitting** `:user` from params does **not** change the link (no "unlink by omission"); unlink is only possible by explicitly passing `:user` (e.g. `user: nil`), which is admin-only.
|
||
|
||
### Approach: One Pair of Actions Plus an Admin-Only `:user` Argument
|
||
|
||
Linking is **not** modelled as separate per-operation actions. The `Mv.Membership.Member`
|
||
resource (`lib/membership/member.ex`) exposes the actions `create_member`, `update_member`,
|
||
`set_vereinfacht_contact_id`, `search`, and `available_for_linking` (plus the default
|
||
`:read`/`:destroy`). Linking and unlinking happen through the optional **`:user` argument** on
|
||
`create_member` / `update_member`, not through dedicated `link_*`/`unlink_*` actions. (`user_id`
|
||
is deliberately **not** in the accept list, so the foreign key cannot be set directly.)
|
||
|
||
### Implementation
|
||
|
||
The user–member link is governed by two facts about `create_member` / `update_member`:
|
||
|
||
- The `:user` argument drives the relationship via `manage_relationship(:user, ...)` with
|
||
`on_lookup: :relate`, `on_no_match: :error`, `on_match: :error`, and **`on_missing: :ignore`**.
|
||
Because of `on_missing: :ignore`, **omitting** `:user` leaves the link unchanged (no "unlink by
|
||
omission"); unlink is explicit (`user: nil`/`user: %{}`), handled on update via the
|
||
`UnrelateUserWhenArgumentNil` change.
|
||
- Whether the `:user` argument may be used at all is gated by the policy check
|
||
`Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin` (`forbid_if` before
|
||
`authorize_if HasPermission` on `action_type([:create, :update])`). It forbids the action for a
|
||
non-admin whenever the `:user` argument **key is present** (any value), so only admins may set or
|
||
change the link. Non-admins can still create/update members as long as they do not pass `:user`.
|
||
|
||
Self-service ("a user creates a member and is linked to it") is handled on the **User** side, not
|
||
by a special Member action: the admin-only `update_user` action takes a `:member` argument for
|
||
link/unlink (see Enforcement above), and the UI gates the linking controls on admin status.
|
||
|
||
### Why This Design?
|
||
|
||
Keeping the link on a single `:user` argument (rather than a fan-out of `link_*`/`unlink_*`
|
||
actions) means there is exactly one create and one update path to reason about, the
|
||
admin-only rule lives in one reusable policy check (`ForbidMemberUserLinkUnlessAdmin`) instead of
|
||
being duplicated per action, and `user_id` can never be mass-assigned because it is not accepted —
|
||
only the argument-driven relationship management can change it.
|
||
|
||
---
|
||
|
||
## 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 an Ash calculation that takes `allowed_fields` from PermissionSets and
|
||
`Map.take/2`s each record to those fields. **Write protection** via an update validation that
|
||
diffs `Map.keys(changeset.attributes)` against the allowed write fields and returns
|
||
`{:error, "You do not have permission to modify: ..."}` for any forbidden field.
|
||
|
||
**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** (shipped 2026-01-08, PR #346, closes #345)
|
||
- Hardcoded PermissionSets module
|
||
- `HasPermission` check reads from module
|
||
- Role table with `permission_set_name` string
|
||
- Zero DB queries for permission checks
|
||
|
||
**What's NOT in MVP (deferred to Phase 3):**
|
||
- `PermissionSetResource` database table
|
||
- `PermissionSetPage` database table
|
||
- ETS Permission Cache
|
||
- Database-backed dynamic permissions / runtime permission editing
|
||
|
||
**MVP DB migration & rollback.** Issue #1 adds a single migration: create the `roles` table (`name` unique, `permission_set_name`, `is_system_role`, timestamps; indexes on `name` and `permission_set_name`) and add nullable `users.role_id` FK (`ON DELETE RESTRICT`) with its index. The migration is additive only — no existing table is modified destructively. The 5 roles are created by `priv/repo/seeds_bootstrap.exs`, and the `assign_mitglied_role_to_existing_users` migration assigns "Mitglied" to users without a role.
|
||
|
||
Rollback options, in order of escalation:
|
||
1. **DB rollback:** the `down` migration drops the `users.role_id` index, removes the `role_id` column, and drops the `roles` table — `mix ecto.rollback --step 1`. Existing tables are untouched.
|
||
2. **Code rollback:** revert the commit and redeploy the previous version.
|
||
|
||
**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
|
||
|
||
Sequence (~3-4 weeks): create the three permission tables + indexes; seed them from
|
||
`PermissionSets.get_permissions/1`; add a `HasResourcePermission` check that queries the DB
|
||
(same logic as `HasPermission`, different data source) backed by the ETS cache with
|
||
invalidation on update; swap `HasPermission` → `HasResourcePermission` in all resources and
|
||
point the UI helper + page plug at the DB/cache; integration + performance/load test; deploy
|
||
behind the feature flag (run both systems in parallel to compare) then gradually to production;
|
||
finally remove the old `HasPermission` check and `PermissionSets` module.
|
||
|
||
---
|
||
|
||
## 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: persist authorization failures (user id, resource, action, outcome,
|
||
reason, IP, timestamp) to an `AuditLog` resource — for tracking suspicious attempts, GDPR access
|
||
logs, and production debugging. Currently failures are only `Logger`-logged.
|
||
|
||
---
|
||
|
||
## Appendix
|
||
|
||
### Glossary
|
||
|
||
- **Permission Set:** Named collection of permissions (e.g., "admin", "read_only")
|
||
- **Role:** Database entity linking users to a permission set; **system role** cannot be deleted (`is_system_role=true`)
|
||
- **Scope:** Range of records a permission applies to (`:own`, `:linked`, `:all`)
|
||
- **Actor:** Currently authenticated user in Ash context
|
||
- **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 or linked user may edit | `Member.Validations.EmailChangePermission` |
|
||
| Own credentials | Always accessible | Special policy before general check |
|
||
|
||
---
|
||
|
||
## 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**
|
||
|