67 KiB
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 - High-level concepts for stakeholders
- Implementation Plan - Step-by-step implementation guide
Table of Contents
- Overview
- Requirements Analysis
- Selected Architecture
- Database Schema (MVP)
- Permission System Design (MVP)
- Resource Policies
- Page Permission System
- UI-Level Authorization
- Special Cases
- User-Member Linking
- Future: Phase 2 - Field-Level Permissions
- Future: Phase 3 - Database-Backed Permissions
- Migration Strategy
- Security Considerations
- 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
- Security First: Authorization is enforced at multiple layers (database policies, page access, UI rendering)
- Performance: MVP uses hardcoded permissions for < 1 microsecond checks
- Maintainability: Clear separation between roles (data) and permissions (logic)
- Extensibility: Clean migration path to database-backed permissions (Phase 3)
- User Experience: Consistent authorization across backend and frontend
- 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 (
rolestable) - 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:
/settingsand/membership_fee_settingsare 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)
- Member:
- :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_userwithrole_id). Last-admin validation ensures at least one user keeps the Admin role. - Settings Pages:
/settingsand/membership_fee_settingsare 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:
- Page Request: Plug checks if user can access page
- UI Rendering: Helper checks which buttons/links to show
- User Action: Ash receives action request (create, read, update, destroy)
- Policy Check:
HasPermissionevaluates permission - Permission Lookup: Reads from
PermissionSetsmodule (in-memory) - Scope Application: Filters query based on scope (:own, :linked, :all)
- 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).
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 descriptionpermission_set_name- References hardcoded permission setis_system_role- If true, role cannot be deleted (protects "Mitglied")
Constraints:
namemust be uniquepermission_set_namemust be one of 4 valid values- System roles cannot be deleted (enforced in Ash resource)
users (modified)
Add foreign key to roles table.
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_permissionis%{resource: String.t(), action:, scope:, granted: boolean}; apermission_setis%{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 raisesArgumentError(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 inall_permission_sets/0.permission_set_name_to_atom/1—String.to_existing_atom/1guarded by validity, and rescuesArgumentError(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_requestsapproval pages. - admin:
"*"wildcard (all pages), with/settingsand/membership_fee_settingsalso 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.
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:
- Resource-Specific :linked Scope: CustomFieldValue needs to traverse
memberrelationship to checkuser_id - Error Handling: All errors log for debugging but return generic forbidden to user
- Module Name Extraction: Uses
Module.split() |> List.last()to match against PermissionSets strings - 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:
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.
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.
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 withexpr()needed ✅ - UPDATE operations (
Ash.update(changeset, actor: user)): Changeset contains record → HasPermission can evaluatescope :owncorrectly → 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.
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 :linkedcorrectly ✅
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).
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).
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.
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).
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 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):
- Extracts the page path as the route template (e.g.
/members/:id) viaPhoenix.Router.route_info/4, falling back toconn.request_path. Using the template, not the concrete path, is what lets the permissionpageslists stay parameterized. (Public paths such as/sign-in,/register,/auth/*are exempt and pass through.) - Reads
current_userfromconn.assigns, resolves itspermission_set_name, and looks up the allowedpagesviaPermissionSets.get_permissions/1. - 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). - 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:
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/settingsand/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 grantsactionon 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 checksrecord.user_id == user.id; CustomFieldValue traversesrecord.member.user_id == user.id(member must be preloaded), with auser_idfallback for other resources.
can_access_page?/2— matches the path against the permission set'spageslist 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)
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 :ownfrom PermissionSets - All permission sets (
:own_data,:read_only,:normal_user,:admin) grantUser.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:
- UPDATE is controlled by PermissionSets, not a hardcoded bypass
- All 4 permission sets (
:own_data,:read_only,:normal_user,:admin) explicitly grant:%{resource: "User", action: :update, scope: :own, granted: true} - HasPermission evaluates
scope :ownfor UPDATE operations (when a changeset with record is present) - 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_useraction (which accepts thememberargument for link/unlink) to admins only viaMv.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:updateaction (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
:userargument (which links the member to a user account) is forbidden for non-admins. This is enforced byMv.Authorization.Checks.ForbidMemberUserLinkUnlessAdminin the Member resource policies (forbid_ifbeforeauthorize_if HasPermission). Non-admins can still create and update members as long as they do not pass the:userargument. The Member resource useson_missing: :ignorefor the:userrelationship on update_member, so omitting:userfrom 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
:userargument drives the relationship viamanage_relationship(:user, ...)withon_lookup: :relate,on_no_match: :error,on_match: :error, andon_missing: :ignore. Because ofon_missing: :ignore, omitting:userleaves the link unchanged (no "unlink by omission"); unlink is explicit (user: nil/user: %{}), handled on update via theUnrelateUserWhenArgumentNilchange. - Whether the
:userargument may be used at all is gated by the policy checkMv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin(forbid_ifbeforeauthorize_if HasPermissiononaction_type([:create, :update])). It forbids the action for a non-admin whenever the:userargument 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:
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/2s 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:
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:
- Create new tables
- Seed from current
PermissionSetsmodule - Create new
HasResourcePermissioncheck that queries DB - Add ETS cache for performance
- Replace
HasPermissionwithHasResourcePermissionin policies - Test thoroughly
- Deploy
- Eventually remove
PermissionSetsmodule
ETS Cache:
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 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
HasPermissioncheck reads from module- Role table with
permission_set_namestring - Zero DB queries for permission checks
What's NOT in MVP (deferred to Phase 3):
PermissionSetResourcedatabase tablePermissionSetPagedatabase 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:
- DB rollback: the
downmigration drops theusers.role_idindex, removes therole_idcolumn, and drops therolestable —mix ecto.rollback --step 1. Existing tables are untouched. - Code rollback: revert the commit and redeploy the previous version.
Phase 2: Field-Level (2-3 weeks) - FUTURE
- Extend PermissionSets with
:fieldskey - 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
HasResourcePermissioncheck 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:
- Unauthorized Data Access: Policies prevent users from accessing data outside their permissions
- Privilege Escalation: Role-based system prevents users from granting themselves higher privileges
- UI Tampering: Backend policies enforce authorization even if UI is bypassed
- Session Hijacking: Mitigation handled by existing authentication system (not in scope)
Threats NOT Addressed:
- SQL Injection: Ash Framework handles query building securely
- XSS: Phoenix LiveView handles HTML escaping
- CSRF: Phoenix CSRF tokens (existing)
Defense in Depth
Three Layers of Authorization:
-
Page Access Layer (Plug):
- Blocks unauthorized page access
- Runs before LiveView mounts
- Fast fail for obvious violations
-
UI Layer (Authorization Helpers):
- Hides buttons/links user can't use
- Prevents confusing "forbidden" errors
- Improves UX
-
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
:rolerelationship 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
bypassorskip_authorizationin 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:
- system_actor - Admin user for systemic operations
- 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:
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:
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:
# priv/repo/seeds.exs
Accounts.create_user!(%{email: admin_email},
authorize?: false # Bootstrap: no admin exists yet
)
2. SystemActor Bootstrap - Chicken-and-egg problem:
# 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:
# 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:
# 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?: falsefor user-initiated actions - ❌ Use
authorize?: falsewhensystem_actorwould 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:
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:
# Seeds - no admin exists yet
Accounts.create_user!(%{email: admin_email}, authorize?: false)
Bad - authorize?: false for user action:
# 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