mitgliederverwaltung/docs/roles-and-permissions-architecture.md
Moritz a19026e430
All checks were successful
continuous-integration/drone/push Build is passing
docs: update roles and permissions architecture and implementation plan
2025-11-13 16:17:01 +01:00

75 KiB

Roles and Permissions Architecture - Technical Specification

Version: 2.0 (Clean Rewrite)
Date: 2025-01-13
Status: Ready for Implementation
Related Documents:


Table of Contents


Overview

This document provides the complete technical specification for the Roles and Permissions system in the Mila membership management application. The system controls who can access what data and which actions they can perform.

Key Design Principles

  1. Security First: Authorization is enforced at multiple layers (database policies, page access, UI rendering)
  2. Performance: MVP uses hardcoded permissions for < 1 microsecond checks
  3. Maintainability: Clear separation between roles (data) and permissions (logic)
  4. Extensibility: Clean migration path to database-backed permissions (Phase 3)
  5. User Experience: Consistent authorization across backend and frontend
  6. Test-Driven: All components fully tested with behavior-focused tests

Architecture Approach

MVP (Phase 1) - Hardcoded Permission Sets:

  • Permission logic in Elixir module (Mv.Authorization.PermissionSets)
  • Role data in database (roles table)
  • Roles reference permission sets by name (string)
  • Zero database queries for permission checks
  • Implementation time: 2-3 weeks

Future (Phase 2) - Field-Level Permissions:

  • Extend PermissionSets with field-level granularity
  • Ash Calculations for read filtering
  • Custom Validations for write protection
  • No database schema changes

Future (Phase 3) - Database-Backed Permissions:

  • Move permission data to database tables
  • Runtime permission configuration
  • ETS cache for performance
  • Migration from hardcoded module

Requirements Analysis

Core Requirements

1. Predefined Permission Sets

Four hardcoded permission sets that define access patterns:

  • own_data - User can only access their own data (default for members)
  • read_only - Read access to all member data, no modifications
  • normal_user - Create/Read/Update on members (no delete), full CRUD on custom fields
  • admin - Unrestricted access including user/role management

2. Roles Stored in Database

Five predefined roles stored in the roles table:

  • Mitglied (Member) → uses "own_data" permission set
  • Vorstand (Board) → uses "read_only" permission set
  • Kassenwart (Treasurer) → uses "normal_user" permission set
  • Buchhaltung (Accounting) → uses "read_only" permission set
  • Admin → uses "admin" permission set

3. Resource-Level Permissions

Control CRUD operations on:

  • User (credentials, profile)
  • Member (member data)
  • Property (custom field values)
  • PropertyType (custom field definitions)
  • Role (role management)

4. Page-Level Permissions

Control access to LiveView pages:

  • Index pages (list views)
  • Show pages (detail views)
  • Form pages (create/edit)
  • Admin pages

5. Granular Scopes

Three scope levels for permissions:

  • :own - Only records where record.id == user.id (for User resource)
  • :linked - Only records linked to user via relationships
    • Member: member.user_id == user.id
    • Property: property.member.user_id == user.id
  • :all - All records, no filtering

6. Special Cases

  • Own Credentials: Every user can always read/update their own credentials
  • Linked Member Email: Only admins can edit email of member linked to user
  • System Roles: "Mitglied" role cannot be deleted (is_system_role flag)
  • User-Member Linking: Only admins can link/unlink users and members

7. UI Consistency

  • UI elements (buttons, links) only shown if user has permission
  • Page access controlled before LiveView mounts
  • Consistent authorization logic between backend and frontend

Selected Architecture

Approach: Hardcoded Permission Sets with Database Roles

Core Concept:

PermissionSets Module (hardcoded in code)
    ↓ (referenced by permission_set_name)
Role (stored in DB: "Vorstand" → "read_only")
    ↓ (assigned to user via role_id)
User (each user has one role)

Why This Approach?

Fast Implementation: 2-3 weeks vs. 4-5 weeks for DB-backed
Maximum Performance: < 1 microsecond per check (pure function call)
Zero DB Overhead: No permission queries, no joins, no cache needed
Git-Tracked Changes: All permission changes in version control
Deterministic Testing: No DB setup, purely functional tests
Clear Migration Path: Well-defined Phase 3 for DB-backed permissions

Trade-offs:

⚠️ Deployment Required: Permission changes need code deployment
⚠️ Four Fixed Sets: Cannot add new permission sets without code change
✔️ Acceptable for MVP: Requirements specify 4 fixed sets, rare changes expected

System Architecture Diagram

┌─────────────────────────────────────────────────────────────┐
│                      Authorization System                    │
└─────────────────────────────────────────────────────────────┘

┌──────────────────┐
│   LiveView       │
│   (UI Layer)     │
└────────┬─────────┘
         │
         │ 1. Page Access Check
         ↓
┌──────────────────────────────────┐
│  CheckPagePermission Plug        │
│  - Reads PermissionSets module   │
│  - Matches page pattern          │
│  - Redirects if unauthorized     │
└────────┬─────────────────────────┘
         │
         │ 2. UI Element Check
         ↓
┌──────────────────────────────────┐
│  MvWeb.Authorization             │
│  - can?/3                        │
│  - can_access_page?/2            │
│  - Uses PermissionSets module    │
└────────┬─────────────────────────┘
         │
         │ 3. Resource Action
         ↓
┌──────────────────────────────────┐
│  Ash Resource (Member, User...)  │
│  - Policies block               │
└────────┬─────────────────────────┘
         │
         │ 4. Policy Evaluation
         ↓
┌──────────────────────────────────┐
│  HasPermission Policy Check      │
│  - Reads actor.role             │
│  - Calls PermissionSets.get_permissions/1 │
│  - Applies scope filter         │
└────────┬─────────────────────────┘
         │
         │ 5. Permission Lookup
         ↓
┌──────────────────────────────────┐
│  PermissionSets Module           │
│  (Hardcoded in code)             │
│  - get_permissions/1             │
│  - Returns {resources, pages}    │
└──────────────────────────────────┘

┌──────────────────────────────────┐
│  Database                        │
│  - roles table                   │
│  - users.role_id → roles.id      │
└──────────────────────────────────┘

Authorization Flow:

  1. Page Request: Plug checks if user can access page
  2. UI Rendering: Helper checks which buttons/links to show
  3. User Action: Ash receives action request (create, read, update, destroy)
  4. Policy Check: HasPermission evaluates permission
  5. Permission Lookup: Reads from PermissionSets module (in-memory)
  6. Scope Application: Filters query based on scope (:own, :linked, :all)
  7. Result: Action succeeds or fails with Forbidden error

Database Schema (MVP)

Overview

The MVP requires only ONE new table: roles

  • Stores role definitions (name, description, permission_set_name)
  • Links to users via foreign key
  • NO permission tables (permissions are hardcoded)

Entity Relationship Diagram

┌─────────────────────────────────┐
│           users                 │
├─────────────────────────────────┤
│ id (PK, UUID)                   │
│ email                           │
│ hashed_password                 │
│ role_id (FK → roles.id)     ◄───┼──┐
│ ...                             │  │
└─────────────────────────────────┘  │
                                     │
                                     │
┌─────────────────────────────────┐  │
│           roles                 │  │
├─────────────────────────────────┤  │
│ id (PK, UUID)                   │──┘
│ name (unique)                   │
│ description                     │
│ permission_set_name (String)    │───┐
│ is_system_role (Boolean)        │   │
│ inserted_at                     │   │
│ updated_at                      │   │
└─────────────────────────────────┘   │
                                      │
                                      │ References one of:
┌─────────────────────────────────┐   │ - "own_data"
│  PermissionSets Module          │◄──┘ - "read_only"
│  (Hardcoded in Code)            │     - "normal_user"
├─────────────────────────────────┤     - "admin"
│ get_permissions(:own_data)      │
│ get_permissions(:read_only)     │
│ get_permissions(:normal_user)   │
│ get_permissions(:admin)         │
└─────────────────────────────────┘

Table Definitions

roles

Stores role definitions that reference permission sets by name.

CREATE TABLE roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL UNIQUE,
  description TEXT,
  permission_set_name VARCHAR(50) NOT NULL,
  is_system_role BOOLEAN NOT NULL DEFAULT false,
  inserted_at TIMESTAMP NOT NULL DEFAULT now(),
  updated_at TIMESTAMP NOT NULL DEFAULT now(),
  
  CONSTRAINT check_valid_permission_set 
    CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin'))
);

CREATE UNIQUE INDEX roles_name_index ON roles (name);
CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name);

Fields:

  • name - Display name (e.g., "Vorstand", "Admin")
  • description - Human-readable description
  • permission_set_name - References hardcoded permission set
  • is_system_role - If true, role cannot be deleted (protects "Mitglied")

Constraints:

  • name must be unique
  • permission_set_name must be one of 4 valid values
  • System roles cannot be deleted (enforced in Ash resource)

users (modified)

Add foreign key to roles table.

ALTER TABLE users 
  ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT;

CREATE INDEX users_role_id_index ON users (role_id);

ON DELETE RESTRICT: Prevents deleting a role if users are assigned to it.

Seed Data

Five predefined roles created during initial setup:

# priv/repo/seeds/authorization_seeds.exs

roles = [
  %{
    name: "Mitglied",
    description: "Default member role with access to own data only",
    permission_set_name: "own_data",
    is_system_role: true  # Cannot be deleted!
  },
  %{
    name: "Vorstand",
    description: "Board member with read access to all member data",
    permission_set_name: "read_only",
    is_system_role: false
  },
  %{
    name: "Kassenwart",
    description: "Treasurer with full member and payment management",
    permission_set_name: "normal_user",
    is_system_role: false
  },
  %{
    name: "Buchhaltung",
    description: "Accounting with read-only access for auditing",
    permission_set_name: "read_only",
    is_system_role: false
  },
  %{
    name: "Admin",
    description: "Administrator with unrestricted access",
    permission_set_name: "admin",
    is_system_role: false
  }
]

# Create roles with idempotent logic
Enum.each(roles, fn role_data ->
  case Ash.get(Mv.Authorization.Role, name: role_data.name) do
    {:ok, existing_role} -> 
      # Update if exists
      Ash.update!(existing_role, role_data)
    {:error, _} -> 
      # Create if not exists
      Ash.create!(Mv.Authorization.Role, role_data)
  end
end)

# Assign "Mitglied" role to users without role
mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied")
users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id)))

Enum.each(users_without_role, fn user ->
  Ash.update!(user, %{role_id: mitglied_role.id})
end)

Permission System Design (MVP)

PermissionSets Module

Location: lib/mv/authorization/permission_sets.ex

This module is the single source of truth for all permissions in the MVP. It defines what each permission set can do.

Module Structure

defmodule Mv.Authorization.PermissionSets do
  @moduledoc """
  Defines the four hardcoded permission sets for the application.
  
  Each permission set specifies:
  - Resource permissions (what CRUD operations on which resources)
  - Page permissions (which LiveView pages can be accessed)
  - Scopes (own, linked, all)
  
  ## Permission Sets
  
  1. **own_data** - Default for "Mitglied" role
     - Can only access own user data and linked member/properties
     - Cannot create new members or manage system
  
  2. **read_only** - For "Vorstand" and "Buchhaltung" roles
     - Can read all member data
     - Cannot create, update, or delete
  
  3. **normal_user** - For "Kassenwart" role
     - Create/Read/Update members (no delete), full CRUD on properties
     - Cannot manage property types or users
  
  4. **admin** - For "Admin" role
     - Unrestricted access to all resources
     - Can manage users, roles, property types
  
  ## Usage
  
      # Get permissions for a role's permission set
      permissions = PermissionSets.get_permissions(:admin)
      
      # Check if a permission set name is valid
      PermissionSets.valid_permission_set?("read_only")  # => true
      
      # Convert string to atom safely
      {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
  
  ## Performance
  
  All functions are pure and compile-time. Permission lookups are < 1 microsecond.
  """

  @type scope :: :own | :linked | :all
  @type action :: :read | :create | :update | :destroy
  
  @type resource_permission :: %{
    resource: String.t(),
    action: action(),
    scope: scope(),
    granted: boolean()
  }
  
  @type permission_set :: %{
    resources: [resource_permission()],
    pages: [String.t()]
  }

  @doc """
  Returns the list of all valid permission set names.
  
  ## Examples
  
      iex> PermissionSets.all_permission_sets()
      [:own_data, :read_only, :normal_user, :admin]
  """
  @spec all_permission_sets() :: [atom()]
  def all_permission_sets do
    [:own_data, :read_only, :normal_user, :admin]
  end

  @doc """
  Returns permissions for the given permission set.
  
  ## Examples
  
      iex> permissions = PermissionSets.get_permissions(:admin)
      iex> Enum.any?(permissions.resources, fn p -> 
      ...>   p.resource == "User" and p.action == :destroy
      ...> end)
      true
      
      iex> PermissionSets.get_permissions(:invalid)
      ** (FunctionClauseError) no function clause matching
  """
  @spec get_permissions(atom()) :: permission_set()
  
  def get_permissions(:own_data) do
    %{
      resources: [
        # User: Can always read/update own credentials
        %{resource: "User", action: :read, scope: :own, granted: true},
        %{resource: "User", action: :update, scope: :own, granted: true},
        
        # Member: Can read/update linked member
        %{resource: "Member", action: :read, scope: :linked, granted: true},
        %{resource: "Member", action: :update, scope: :linked, granted: true},
        
        # Property: Can read/update properties of linked member
        %{resource: "Property", action: :read, scope: :linked, granted: true},
        %{resource: "Property", action: :update, scope: :linked, granted: true},
        
        # PropertyType: Can read all (needed for forms)
        %{resource: "PropertyType", action: :read, scope: :all, granted: true}
      ],
      pages: [
        "/",                  # Home page
        "/profile",           # Own profile
        "/members/:id"        # Linked member detail (filtered by policy)
      ]
    }
  end

  def get_permissions(:read_only) do
    %{
      resources: [
        # User: Can read/update own credentials only
        %{resource: "User", action: :read, scope: :own, granted: true},
        %{resource: "User", action: :update, scope: :own, granted: true},
        
        # Member: Can read all members, no modifications
        %{resource: "Member", action: :read, scope: :all, granted: true},
        
        # Property: Can read all properties
        %{resource: "Property", action: :read, scope: :all, granted: true},
        
        # PropertyType: Can read all
        %{resource: "PropertyType", action: :read, scope: :all, granted: true}
      ],
      pages: [
        "/",
        "/members",           # Member list
        "/members/:id",       # Member detail
        "/properties",        # Property overview
        "/profile"            # Own profile
      ]
    }
  end

  def get_permissions(:normal_user) do
    %{
      resources: [
        # User: Can read/update own credentials only
        %{resource: "User", action: :read, scope: :own, granted: true},
        %{resource: "User", action: :update, scope: :own, granted: true},
        
        # Member: Full CRUD
        %{resource: "Member", action: :read, scope: :all, granted: true},
        %{resource: "Member", action: :create, scope: :all, granted: true},
        %{resource: "Member", action: :update, scope: :all, granted: true},
        # Note: destroy intentionally omitted for safety
        
        # Property: Full CRUD
        %{resource: "Property", action: :read, scope: :all, granted: true},
        %{resource: "Property", action: :create, scope: :all, granted: true},
        %{resource: "Property", action: :update, scope: :all, granted: true},
        %{resource: "Property", action: :destroy, scope: :all, granted: true},
        
        # PropertyType: Read only (admin manages definitions)
        %{resource: "PropertyType", action: :read, scope: :all, granted: true}
      ],
      pages: [
        "/",
        "/members",
        "/members/new",        # Create member
        "/members/:id",
        "/members/:id/edit",   # Edit member
        "/properties",
        "/properties/new",
        "/properties/:id/edit",
        "/profile"
      ]
    }
  end

  def get_permissions(:admin) do
    %{
      resources: [
        # User: Full management including other users
        %{resource: "User", action: :read, scope: :all, granted: true},
        %{resource: "User", action: :create, scope: :all, granted: true},
        %{resource: "User", action: :update, scope: :all, granted: true},
        %{resource: "User", action: :destroy, scope: :all, granted: true},
        
        # Member: Full CRUD
        %{resource: "Member", action: :read, scope: :all, granted: true},
        %{resource: "Member", action: :create, scope: :all, granted: true},
        %{resource: "Member", action: :update, scope: :all, granted: true},
        %{resource: "Member", action: :destroy, scope: :all, granted: true},
        
        # Property: Full CRUD
        %{resource: "Property", action: :read, scope: :all, granted: true},
        %{resource: "Property", action: :create, scope: :all, granted: true},
        %{resource: "Property", action: :update, scope: :all, granted: true},
        %{resource: "Property", action: :destroy, scope: :all, granted: true},
        
        # PropertyType: Full CRUD (admin manages custom field definitions)
        %{resource: "PropertyType", action: :read, scope: :all, granted: true},
        %{resource: "PropertyType", action: :create, scope: :all, granted: true},
        %{resource: "PropertyType", action: :update, scope: :all, granted: true},
        %{resource: "PropertyType", action: :destroy, scope: :all, granted: true},
        
        # Role: Full CRUD (admin manages roles)
        %{resource: "Role", action: :read, scope: :all, granted: true},
        %{resource: "Role", action: :create, scope: :all, granted: true},
        %{resource: "Role", action: :update, scope: :all, granted: true},
        %{resource: "Role", action: :destroy, scope: :all, granted: true}
      ],
      pages: [
        "*"  # Wildcard: Admin can access all pages
      ]
    }
  end

  @doc """
  Checks if a permission set name (string or atom) is valid.
  
  ## Examples
  
      iex> PermissionSets.valid_permission_set?("admin")
      true
      
      iex> PermissionSets.valid_permission_set?(:read_only)
      true
      
      iex> PermissionSets.valid_permission_set?("invalid")
      false
  """
  @spec valid_permission_set?(String.t() | atom()) :: boolean()
  def valid_permission_set?(name) when is_binary(name) do
    case permission_set_name_to_atom(name) do
      {:ok, _atom} -> true
      {:error, _} -> false
    end
  end
  
  def valid_permission_set?(name) when is_atom(name) do
    name in all_permission_sets()
  end

  @doc """
  Converts a permission set name string to atom safely.
  
  ## Examples
  
      iex> PermissionSets.permission_set_name_to_atom("admin")
      {:ok, :admin}
      
      iex> PermissionSets.permission_set_name_to_atom("invalid")
      {:error, :invalid_permission_set}
  """
  @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set}
  def permission_set_name_to_atom(name) when is_binary(name) do
    atom = String.to_existing_atom(name)
    if valid_permission_set?(atom) do
      {:ok, atom}
    else
      {:error, :invalid_permission_set}
    end
  rescue
    ArgumentError -> {:error, :invalid_permission_set}
  end
end

Permission Matrix

Quick reference table showing what each permission set allows:

Resource own_data read_only normal_user admin
User (own) R, U R, U R, U R, U
User (all) - - - R, C, U, D
Member (linked) R, U - - -
Member (all) - R R, C, U R, C, U, D
Property (linked) R, U - - -
Property (all) - R R, C, U, D R, C, U, D
PropertyType (all) R R R R, C, U, D
Role (all) - - - R, C, U, D

Legend: R=Read, C=Create, U=Update, D=Destroy

HasPermission Policy Check

Location: lib/mv/authorization/checks/has_permission.ex

This is a custom Ash Policy Check that evaluates permissions from the PermissionSets module.

defmodule Mv.Authorization.Checks.HasPermission do
  @moduledoc """
  Custom Ash Policy Check that evaluates permissions from the PermissionSets module.
  
  This check:
  1. Reads the actor's role and permission_set_name
  2. Looks up permissions from PermissionSets.get_permissions/1
  3. Finds matching permission for current resource + action
  4. Applies scope filter (:own, :linked, :all)
  
  ## Usage in Ash Resource
  
      policies do
        policy action_type(:read) do
          authorize_if Mv.Authorization.Checks.HasPermission
        end
      end
  
  ## Scope Behavior
  
  - **:all** - Authorizes without filtering (returns all records)
  - **:own** - Filters to records where record.id == actor.id
  - **:linked** - Filters based on resource type:
    - Member: member.user_id == actor.id
    - Property: property.member.user_id == actor.id (traverses relationship!)
  
  ## Error Handling
  
  Returns `{:error, reason}` for:
  - Missing actor
  - Actor without role
  - Invalid permission_set_name
  - No matching permission found
  
  All errors result in Forbidden (policy fails).
  """
  
  use Ash.Policy.Check
  require Ash.Query
  import Ash.Expr
  alias Mv.Authorization.PermissionSets

  @impl true
  def describe(_opts) do
    "checks if actor has permission via their role's permission set"
  end

  @impl true
  def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do
    with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
         {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
         permissions <- PermissionSets.get_permissions(ps_atom),
         resource_name <- get_resource_name(resource) do
      check_permission(permissions.resources, resource_name, action, actor, resource_name)
    else
      %{role: nil} -> 
        log_auth_failure(actor, resource, action, "no role assigned")
        {:error, :no_role}
      
      %{role: %{permission_set_name: nil}} -> 
        log_auth_failure(actor, resource, action, "role has no permission_set_name")
        {:error, :no_permission_set}
      
      {:error, :invalid_permission_set} = error -> 
        log_auth_failure(actor, resource, action, "invalid permission_set_name")
        error
      
      _ -> 
        log_auth_failure(actor, resource, action, "no actor or missing data")
        {:error, :no_permission}
    end
  end

  # Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
  defp get_resource_name(resource) when is_atom(resource) do
    resource |> Module.split() |> List.last()
  end

  # Find matching permission and apply scope
  defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do
    case Enum.find(resource_perms, fn perm ->
      perm.resource == resource_name and
      perm.action == action and
      perm.granted
    end) do
      nil -> 
        {:error, :no_permission}
      
      perm -> 
        apply_scope(perm.scope, actor, resource_name)
    end
  end

  # Scope: all - No filtering, access to all records
  defp apply_scope(:all, _actor, _resource) do
    :authorized
  end

  # Scope: own - Filter to records where record.id == actor.id
  # Used for User resource (users can access their own user record)
  defp apply_scope(:own, actor, _resource) do
    {:filter, expr(id == ^actor.id)}
  end

  # Scope: linked - Filter based on user_id relationship (resource-specific!)
  defp apply_scope(:linked, actor, resource_name) do
    case resource_name do
      "Member" -> 
        # Member.user_id == actor.id (direct relationship)
        {:filter, expr(user_id == ^actor.id)}
      
      "Property" -> 
        # Property.member.user_id == actor.id (traverse through member!)
        {:filter, expr(member.user_id == ^actor.id)}
      
      _ -> 
        # Fallback for other resources: try direct user_id
        {:filter, expr(user_id == ^actor.id)}
    end
  end

  # Log authorization failures for debugging
  defp log_auth_failure(actor, resource, action, reason) do
    require Logger
    
    actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
    resource_name = get_resource_name(resource)
    
    Logger.debug("""
    Authorization failed:
      Actor: #{actor_id}
      Resource: #{resource_name}
      Action: #{action}
      Reason: #{reason}
    """)
  end
end

Key Design Decisions:

  1. Resource-Specific :linked Scope: Property needs to traverse member relationship to check user_id
  2. Error Handling: All errors log for debugging but return generic forbidden to user
  3. Module Name Extraction: Uses Module.split() |> List.last() to match against PermissionSets strings
  4. Pure Function: No side effects, deterministic, easily testable

Resource Policies

Each Ash resource defines policies that use the HasPermission check. This section documents the policy structure for each resource.

General Policy Pattern

All resources follow this pattern:

policies do
  # 1. Special cases first (most specific)
  policy action_type(:read) do
    authorize_if expr(condition_for_special_case)
  end
  
  # 2. General authorization (uses PermissionSets)
  policy action_type([:read, :create, :update, :destroy]) do
    authorize_if Mv.Authorization.Checks.HasPermission
  end
  
  # 3. Default: Forbid
  policy action_type([:read, :create, :update, :destroy]) do
    forbid_if always()
  end
end

Policy Order Matters! Ash evaluates policies top-to-bottom, first match wins.

User Resource Policies

Location: lib/mv/accounts/user.ex

Special Case: Users can ALWAYS read/update their own credentials, regardless of role.

defmodule Mv.Accounts.User do
  use Ash.Resource, ...
  
  policies do
    # SPECIAL CASE: Users can always access their own account
    # This takes precedence over permission checks
    policy action_type([:read, :update]) do
      description "Users can always read and update their own account"
      authorize_if expr(id == ^actor(:id))
    end
    
    # GENERAL: Other operations require permission
    # (e.g., admin reading/updating other users, admin destroying users)
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    
    # DEFAULT: Forbid if no policy matched
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
  
  # ...
end

Permission Matrix:

Action Mitglied Vorstand Kassenwart Buchhaltung Admin
Read own (special) (special) (special) (special)
Update own (special) (special) (special) (special)
Read others
Update others
Create
Destroy

Member Resource Policies

Location: lib/mv/membership/member.ex

Special Case: Users can always access their linked member (where member.user_id == user.id).

defmodule Mv.Membership.Member do
  use Ash.Resource, ...
  
  policies do
    # SPECIAL CASE: Users can always access their linked member
    policy action_type([:read, :update]) do
      description "Users can access member linked to their account"
      authorize_if expr(user_id == ^actor(:id))
    end
    
    # GENERAL: Check permissions from role
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    
    # DEFAULT: Forbid
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
  
  # Custom validation for email editing (see Special Cases section)
  validations do
    validate changing(:email), on: :update do
      validate &validate_linked_member_email_change/2
    end
  end
  
  # ...
end

Permission Matrix:

Action Mitglied Vorstand Kassenwart Buchhaltung Admin
Read linked (special) (if linked) (if linked)
Update linked (special)* *
Read all
Create
Destroy

*Email editing has additional validation (see Special Cases)

Property Resource Policies

Location: lib/mv/membership/property.ex

Special Case: Users can access properties of their linked member.

defmodule Mv.Membership.Property do
  use Ash.Resource, ...
  
  policies do
    # SPECIAL CASE: Users can access properties of their linked member
    # Note: This traverses the member relationship!
    policy action_type([:read, :update]) do
      description "Users can access properties of their linked member"
      authorize_if expr(member.user_id == ^actor(:id))
    end
    
    # GENERAL: Check permissions from role
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    
    # DEFAULT: Forbid
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
  
  # ...
end

Permission Matrix:

Action Mitglied Vorstand Kassenwart Buchhaltung Admin
Read linked (special) (if linked) (if linked)
Update linked (special)
Read all
Create
Destroy

PropertyType Resource Policies

Location: lib/mv/membership/property_type.ex

No Special Cases: All users can read, only admin can write.

defmodule Mv.Membership.PropertyType do
  use Ash.Resource, ...
  
  policies do
    # All authenticated users can read property types (needed for forms)
    # Write operations are admin-only
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    
    # DEFAULT: Forbid
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
  
  # ...
end

Permission Matrix:

Action Mitglied Vorstand Kassenwart Buchhaltung Admin
Read
Create
Update
Destroy

Role Resource Policies

Location: lib/mv/authorization/role.ex

Special Protection: System roles cannot be deleted.

defmodule Mv.Authorization.Role do
  use Ash.Resource, ...
  
  policies do
    # Only admin can manage roles
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end
    
    # DEFAULT: Forbid
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
  
  # Prevent deletion of system roles
  validations do
    validate action(:destroy) do
      validate fn _changeset, %{data: role} ->
        if role.is_system_role do
          {:error, "Cannot delete system role"}
        else
          :ok
        end
      end
    end
  end
  
  # Validate permission_set_name
  validations do
    validate attribute(:permission_set_name) do
      validate fn _changeset, value ->
        if PermissionSets.valid_permission_set?(value) do
          :ok
        else
          {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"}
        end
      end
    end
  end
  
  # ...
end

Permission Matrix:

Action Mitglied Vorstand Kassenwart Buchhaltung Admin
Read
Create
Update
Destroy*

*Cannot destroy if is_system_role=true


Page Permission System

Page permissions control which LiveView pages a user can access. This is enforced before the LiveView mounts via a Phoenix Plug.

CheckPagePermission Plug

Location: lib/mv_web/plugs/check_page_permission.ex

This plug runs in the router pipeline and checks if the current user has permission to access the requested page.

defmodule MvWeb.Plugs.CheckPagePermission do
  @moduledoc """
  Plug that checks if current user has permission to access the current page.
  
  ## How It Works
  
  1. Extracts page path from conn (route template like "/members/:id")
  2. Gets current user from conn.assigns
  3. Gets user's permission_set_name from role
  4. Calls PermissionSets.get_permissions/1 to get allowed pages
  5. Matches requested path against allowed patterns
  6. If unauthorized: redirects to "/" with flash error
  
  ## Pattern Matching
  
  - Exact match: "/members" == "/members"
  - Dynamic routes: "/members/:id" matches "/members/123"
  - Wildcard: "*" matches everything (admin)
  
  ## Usage in Router
  
      pipeline :require_page_permission do
        plug MvWeb.Plugs.CheckPagePermission
      end
      
      scope "/members", MvWeb do
        pipe_through [:browser, :require_authenticated_user, :require_page_permission]
        
        live "/", MemberLive.Index
        live "/:id", MemberLive.Show
      end
  """
  
  import Plug.Conn
  import Phoenix.Controller
  alias Mv.Authorization.PermissionSets
  require Logger

  def init(opts), do: opts

  def call(conn, _opts) do
    user = conn.assigns[:current_user]
    page_path = get_page_path(conn)
    
    if has_page_permission?(user, page_path) do
      conn
    else
      log_page_access_denied(user, page_path)
      
      conn
      |> put_flash(:error, "You don't have permission to access this page.")
      |> redirect(to: "/")
      |> halt()
    end
  end

  # Extract page path from conn (route template preferred, fallback to request_path)
  defp get_page_path(conn) do
    case conn.private[:phoenix_route] do
      {_plug, _opts, _pipe, route_template, _meta} -> 
        route_template
      
      _ -> 
        conn.request_path
    end
  end

  # Check if user has permission for page
  defp has_page_permission?(nil, _page_path) do
    false
  end
  
  defp has_page_permission?(user, page_path) do
    with %{role: %{permission_set_name: ps_name}} <- user,
         {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
         permissions <- PermissionSets.get_permissions(ps_atom) do
      page_matches?(permissions.pages, page_path)
    else
      _ -> false
    end
  end

  # Check if requested path matches any allowed pattern
  defp page_matches?(allowed_pages, requested_path) do
    Enum.any?(allowed_pages, fn pattern ->
      cond do
        # Wildcard: admin can access all pages
        pattern == "*" -> 
          true
        
        # Exact match
        pattern == requested_path -> 
          true
        
        # Dynamic route match (e.g., "/members/:id" matches "/members/123")
        String.contains?(pattern, ":") ->
          match_dynamic_route?(pattern, requested_path)
        
        # No match
        true -> 
          false
      end
    end)
  end

  # Match dynamic route pattern against actual path
  defp match_dynamic_route?(pattern, path) do
    pattern_segments = String.split(pattern, "/", trim: true)
    path_segments = String.split(path, "/", trim: true)
    
    # Must have same number of segments
    if length(pattern_segments) == length(path_segments) do
      Enum.zip(pattern_segments, path_segments)
      |> Enum.all?(fn {pattern_seg, path_seg} ->
        # Dynamic segment (starts with :) matches anything
        String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
      end)
    else
      false
    end
  end

  defp log_page_access_denied(user, page_path) do
    user_id = if is_map(user), do: Map.get(user, :id), else: "nil"
    role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil"
    
    Logger.info("""
    Page access denied:
      User: #{user_id}
      Role: #{role}
      Page: #{page_path}
    """)
  end
end

Router Integration

Add plug to protected routes:

defmodule MvWeb.Router do
  use MvWeb, :router

  pipeline :require_page_permission do
    plug MvWeb.Plugs.CheckPagePermission
  end

  # Public routes (no authentication)
  scope "/", MvWeb do
    pipe_through :browser
    
    live "/", PageController, :home
    get "/login", AuthController, :new
    post "/login", AuthController, :create
  end

  # Protected routes (authentication + page permission)
  scope "/members", MvWeb do
    pipe_through [:browser, :require_authenticated_user, :require_page_permission]
    
    live "/", MemberLive.Index, :index
    live "/new", MemberLive.Form, :new
    live "/:id", MemberLive.Show, :show
    live "/:id/edit", MemberLive.Form, :edit
  end

  # Admin routes
  scope "/admin", MvWeb do
    pipe_through [:browser, :require_authenticated_user, :require_page_permission]
    
    live "/roles", RoleLive.Index, :index
    live "/roles/:id", RoleLive.Show, :show
  end
end

Page Permission Examples

Mitglied (own_data):

  • Can access: /, /profile, /members/123 (if 123 is their linked member)
  • Cannot access: /members, /members/new, /admin/roles

Vorstand (read_only):

  • Can access: /, /members, /members/123, /properties, /profile
  • Cannot access: /members/new, /members/123/edit, /admin/roles

Kassenwart (normal_user):

  • Can access: /, /members, /members/new, /members/123/edit, /properties, /profile
  • Cannot access: /admin/roles, /admin/property_types/new

Admin:

  • Can access: * (all pages, including /admin/roles)

UI-Level Authorization

UI-level authorization ensures that users only see buttons, links, and form fields they have permission to use. This provides a consistent user experience and prevents confusing "forbidden" errors.

MvWeb.Authorization Helper Module

Location: lib/mv_web/authorization.ex

This module provides helper functions for conditional rendering in LiveView templates.

defmodule MvWeb.Authorization do
  @moduledoc """
  UI-level authorization helpers for LiveView templates.
  
  These functions check if the current user has permission to perform actions
  or access pages. They use the same PermissionSets module as the backend policies,
  ensuring UI and backend authorization are consistent.
  
  ## Usage in Templates
  
      <!-- Conditional button rendering -->
      <%= if can?(@current_user, :create, Mv.Membership.Member) do %>
        <.link patch={~p"/members/new"}>New Member</.link>
      <% end %>
      
      <!-- Record-level check -->
      <%= if can?(@current_user, :update, @member) do %>
        <.button>Edit</.button>
      <% end %>
      
      <!-- Page access check -->
      <%= if can_access_page?(@current_user, "/admin/roles") do %>
        <.link navigate="/admin/roles">Manage Roles</.link>
      <% end %>
  
  ## Performance
  
  All checks are pure function calls using the hardcoded PermissionSets module.
  No database queries, < 1 microsecond per check.
  """
  
  alias Mv.Authorization.PermissionSets

  @doc """
  Checks if user has permission for an action on a resource (atom).
  
  ## Examples
  
      iex> admin = %{role: %{permission_set_name: "admin"}}
      iex> can?(admin, :create, Mv.Membership.Member)
      true
      
      iex> mitglied = %{role: %{permission_set_name: "own_data"}}
      iex> can?(mitglied, :create, Mv.Membership.Member)
      false
  """
  @spec can?(map() | nil, atom(), atom()) :: boolean()
  def can?(nil, _action, _resource), do: false
  
  def can?(user, action, resource) when is_atom(action) and is_atom(resource) do
    with %{role: %{permission_set_name: ps_name}} <- user,
         {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
         permissions <- PermissionSets.get_permissions(ps_atom) do
      resource_name = get_resource_name(resource)
      
      Enum.any?(permissions.resources, fn perm ->
        perm.resource == resource_name and
        perm.action == action and
        perm.granted
      end)
    else
      _ -> false
    end
  end

  @doc """
  Checks if user has permission for an action on a specific record (struct).
  
  Applies scope checking:
  - :own - record.id == user.id
  - :linked - record.user_id == user.id (or traverses relationships)
  - :all - always true
  
  ## Examples
  
      iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}}
      iex> member = %Member{id: "member-456", user_id: "user-123"}
      iex> can?(user, :update, member)
      true
      
      iex> other_member = %Member{id: "member-789", user_id: "other-user"}
      iex> can?(user, :update, other_member)
      false
  """
  @spec can?(map() | nil, atom(), struct()) :: boolean()
  def can?(nil, _action, _record), do: false
  
  def can?(user, action, %resource{} = record) when is_atom(action) do
    with %{role: %{permission_set_name: ps_name}} <- user,
         {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
         permissions <- PermissionSets.get_permissions(ps_atom) do
      resource_name = get_resource_name(resource)
      
      # Find matching permission
      matching_perm = Enum.find(permissions.resources, fn perm ->
        perm.resource == resource_name and
        perm.action == action and
        perm.granted
      end)
      
      case matching_perm do
        nil -> false
        perm -> check_scope(perm.scope, user, record, resource_name)
      end
    else
      _ -> false
    end
  end

  @doc """
  Checks if user can access a specific page.
  
  ## Examples
  
      iex> admin = %{role: %{permission_set_name: "admin"}}
      iex> can_access_page?(admin, "/admin/roles")
      true
      
      iex> mitglied = %{role: %{permission_set_name: "own_data"}}
      iex> can_access_page?(mitglied, "/members")
      false
  """
  @spec can_access_page?(map() | nil, String.t()) :: boolean()
  def can_access_page?(nil, _page_path), do: false
  
  def can_access_page?(user, page_path) do
    with %{role: %{permission_set_name: ps_name}} <- user,
         {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
         permissions <- PermissionSets.get_permissions(ps_atom) do
      page_matches?(permissions.pages, page_path)
    else
      _ -> false
    end
  end

  # Check if scope allows access to record
  defp check_scope(:all, _user, _record, _resource_name), do: true
  
  defp check_scope(:own, user, record, _resource_name) do
    record.id == user.id
  end
  
  defp check_scope(:linked, user, record, resource_name) do
    case resource_name do
      "Member" -> 
        # Direct relationship: member.user_id
        Map.get(record, :user_id) == user.id
      
      "Property" -> 
        # Need to traverse: property.member.user_id
        # Note: In UI, property should have member preloaded
        case Map.get(record, :member) do
          %{user_id: member_user_id} -> member_user_id == user.id
          _ -> false
        end
      
      _ -> 
        # Fallback: check user_id
        Map.get(record, :user_id) == user.id
    end
  end

  # Check if page path matches any allowed pattern
  defp page_matches?(allowed_pages, requested_path) do
    Enum.any?(allowed_pages, fn pattern ->
      cond do
        pattern == "*" -> true
        pattern == requested_path -> true
        String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
        true -> false
      end
    end)
  end

  # Match dynamic route pattern
  defp match_pattern?(pattern, path) do
    pattern_segments = String.split(pattern, "/", trim: true)
    path_segments = String.split(path, "/", trim: true)
    
    if length(pattern_segments) == length(path_segments) do
      Enum.zip(pattern_segments, path_segments)
      |> Enum.all?(fn {pattern_seg, path_seg} ->
        String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
      end)
    else
      false
    end
  end

  # Extract resource name from module
  defp get_resource_name(resource) when is_atom(resource) do
    resource |> Module.split() |> List.last()
  end
end

Import in mv_web.ex

Make helpers available to all LiveViews:

defmodule MvWeb do
  # ...
  
  def html_helpers do
    quote do
      # ... existing helpers ...
      
      # Authorization helpers
      import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
    end
  end
  
  # ...
end

UI Examples

Navbar with conditional links:

<!-- lib/mv_web/components/layouts/navbar.html.heex -->
<nav class="navbar">
  <!-- Always visible -->
  <.link navigate="/">Home</.link>
  
  <!-- Members link (read or write access) -->
  <%= if can_access_page?(@current_user, "/members") do %>
    <.link navigate="/members">Members</.link>
  <% end %>
  
  <!-- Admin dropdown (admin only) -->
  <%= if can_access_page?(@current_user, "/admin/roles") do %>
    <div class="dropdown">
      <span>Admin</span>
      <ul>
        <li><.link navigate="/admin/roles">Roles</.link></li>
        <li><.link navigate="/admin/property_types">Property Types</.link></li>
      </ul>
    </div>
  <% end %>
  
  <!-- Profile (always visible for authenticated users) -->
  <.link navigate="/profile">Profile</.link>
</nav>

Index page with conditional "New" button:

<!-- lib/mv_web/live/member_live/index.html.heex -->
<div class="page-header">
  <h1>Members</h1>
  
  <!-- Only show if user can create members -->
  <%= if can?(@current_user, :create, Mv.Membership.Member) do %>
    <.link patch={~p"/members/new"} class="btn-primary">
      New Member
    </.link>
  <% end %>
</div>

<table>
  <!-- ... -->
  <%= for member <- @members do %>
    <tr>
      <td><%= member.name %></td>
      <td>
        <!-- Show edit button only if user can update THIS member -->
        <%= if can?(@current_user, :update, member) do %>
          <.link patch={~p"/members/#{member.id}/edit"}>Edit</.link>
        <% end %>
        
        <!-- Show delete button only if user can destroy THIS member -->
        <%= if can?(@current_user, :destroy, member) do %>
          <.button phx-click="delete" phx-value-id={member.id}>Delete</.button>
        <% end %>
      </td>
    </tr>
  <% end %>
</table>

Show page with conditional edit button:

<!-- lib/mv_web/live/member_live/show.html.heex -->
<div class="member-detail">
  <h1><%= @member.name %></h1>
  
  <dl>
    <dt>Email</dt>
    <dd><%= @member.email %></dd>
    
    <dt>Address</dt>
    <dd><%= @member.address %></dd>
  </dl>
  
  <!-- Edit button only if user can update -->
  <%= if can?(@current_user, :update, @member) do %>
    <.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary">
      Edit Member
    </.link>
  <% end %>
</div>

Special Cases

1. Own Credentials Access

Requirement: Every user can ALWAYS read and update their own credentials (email, password), regardless of their role.

Implementation:

Policy in User resource places this check BEFORE the general HasPermission check:

policies do
  # SPECIAL CASE: Takes precedence over role permissions
  policy action_type([:read, :update]) do
    description "Users can always read and update their own account"
    authorize_if expr(id == ^actor(:id))
  end
  
  # GENERAL: For other operations (e.g., admin reading other users)
  policy action_type([:read, :create, :update, :destroy]) do
    authorize_if Mv.Authorization.Checks.HasPermission
  end
end

Why this works:

  • Ash evaluates policies top-to-bottom
  • First matching policy wins
  • Special case catches own-account access before checking permissions
  • Even a user with own_data (no admin permissions) can update their credentials

2. Linked Member Email Editing

Requirement: Only administrators can edit the email of a member that is linked to a user (has user_id set). This prevents breaking email synchronization.

Implementation:

Custom validation in Member resource:

defmodule Mv.Membership.Member do
  use Ash.Resource, ...
  
  validations do
    # Only run when email is being changed
    validate changing(:email), on: :update do
      validate &validate_linked_member_email_change/2
    end
  end

  defp validate_linked_member_email_change(changeset, _context) do
    member = changeset.data
    actor = changeset.context[:actor]
    
    # If member is not linked to user, allow change
    if is_nil(member.user_id) do
      :ok
    else
      # Member is linked - check if actor is admin
      if has_admin_permission?(actor) do
        :ok
      else
        {:error, "Only administrators can change email for members linked to user accounts"}
      end
    end
  end

  defp has_admin_permission?(nil), do: false
  
  defp has_admin_permission?(actor) do
    with %{role: %{permission_set_name: ps_name}} <- actor,
         {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
         permissions <- PermissionSets.get_permissions(ps_atom) do
      # Check if actor has User.update permission with scope :all (admin privilege)
      Enum.any?(permissions.resources, fn perm ->
        perm.resource == "User" and
        perm.action == :update and
        perm.scope == :all and
        perm.granted
      end)
    else
      _ -> false
    end
  end
end

Why this is needed:

  • Member email and User email are kept in sync
  • If a non-admin changes linked member email, it could create inconsistency
  • Validation runs AFTER policy check, so normal_user can update member
  • But validation blocks email field specifically if member is linked

3. System Role Protection

Requirement: The "Mitglied" role cannot be deleted because it's the default role for all users.

Implementation:

Flag + validation in Role resource:

defmodule Mv.Authorization.Role do
  use Ash.Resource, ...
  
  attributes do
    # ...
    attribute :is_system_role, :boolean, default: false
  end
  
  validations do
    validate action(:destroy) do
      validate fn _changeset, %{data: role} ->
        if role.is_system_role do
          {:error, "Cannot delete system role. System roles are required for the application to function."}
        else
          :ok
        end
      end
    end
  end
end

Seeds set the flag:

%{
  name: "Mitglied",
  permission_set_name: "own_data",
  is_system_role: true  # <-- Protected!
}

UI hides delete button:

<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %>
  <.button phx-click="delete">Delete</.button>
<% end %>

4. User Without Role (Edge Case)

Requirement: Users without a role should be denied all access (except logout).

Implementation:

Default Assignment: Seeds assign "Mitglied" role to all existing users

# In authorization_seeds.exs
mitglied_role = Ash.get!(Role, name: "Mitglied")
users_without_role = Ash.read!(User, filter: expr(is_nil(role_id)))

Enum.each(users_without_role, fn user ->
  Ash.update!(user, %{role_id: mitglied_role.id})
end)

Runtime Handling: All authorization checks handle missing role gracefully

# In HasPermission check
def match?(actor, %{resource: resource, action: action}, _opts) do
  with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
       # ...
  else
    %{role: nil} -> 
      {:error, :no_role}  # User has no role -> forbidden
    
    _ -> 
      {:error, :no_permission}
  end
end

Result: User with no role sees empty UI, cannot access pages, gets forbidden on all actions.

5. Invalid permission_set_name (Edge Case)

Requirement: If a role has an invalid permission_set_name, fail gracefully without crashing.

Implementation:

Prevention: Validation on Role resource

validations do
  validate attribute(:permission_set_name) do
    validate fn _changeset, value ->
      if PermissionSets.valid_permission_set?(value) do
        :ok
      else
        {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"}
      end
    end
  end
end

Runtime Handling: All lookups check validity

# In PermissionSets module
def permission_set_name_to_atom(name) when is_binary(name) do
  atom = String.to_existing_atom(name)
  if valid_permission_set?(atom) do
    {:ok, atom}
  else
    {:error, :invalid_permission_set}
  end
rescue
  ArgumentError -> {:error, :invalid_permission_set}
end

Result: Invalid permission_set_name → authorization fails → forbidden (safe default).


User-Member Linking

Requirement

Users and Members are separate entities that can be linked. Special rules:

  • Only admins can link/unlink users and members
  • A user cannot link themselves to an existing member
  • A user CAN create a new member and be directly linked to it (self-service)

Approach: Separate Ash Actions

We use different Ash actions to enforce different policies:

  1. create_member_for_self - User creates member and links to themselves
  2. create_member - Admin creates member for any user (or unlinked)
  3. link_member_to_user - Admin links existing member to user
  4. unlink_member_from_user - Admin removes user link
  5. update - Standard update (cannot change user_id)

Implementation

defmodule Mv.Membership.Member do
  use Ash.Resource, ...
  
  actions do
    # SELF-SERVICE: User creates member and links to self
    create :create_member_for_self do
      description "User creates a new member and links it to their own account"
      
      accept [:name, :email, :address, ...]  # All fields except user_id
      
      # Automatically set user_id to actor
      change set_attribute(:user_id, actor(:id))
      
      # Prevent creating multiple members for same user (optional business rule)
      validate fn changeset, _context ->
        actor_id = get_change(changeset, :user_id)
        
        case Ash.read(Member, filter: expr(user_id == ^actor_id)) do
          {:ok, []} -> :ok  # No existing member, allow
          {:ok, [_member | _]} -> {:error, "You already have a member profile"}
          {:error, _} -> :ok
        end
      end
    end

    # ADMIN: Create member with optional user link
    create :create_member do
      description "Admin creates a new member, optionally linked to a user"
      
      accept [:name, :email, :address, ..., :user_id]  # Admin can set user_id
    end

    # ADMIN: Link existing member to user
    update :link_member_to_user do
      description "Admin links an existing member to a user account"
      
      accept [:user_id]
      
      validate fn changeset, _context ->
        member = changeset.data
        
        # Cannot link if already linked
        if is_nil(member.user_id) do
          :ok
        else
          {:error, "Member is already linked to a user"}
        end
      end
    end

    # ADMIN: Remove user link from member
    update :unlink_member_from_user do
      description "Admin removes user link from member"
      
      change set_attribute(:user_id, nil)
    end

    # STANDARD UPDATE: Cannot change user_id
    update :update do
      description "Update member data (cannot change user link)"
      
      accept [:name, :email, :address, ...]  # user_id NOT in accept list
    end
  end

  policies do
    # Self-service member creation
    policy action(:create_member_for_self) do
      description "Any authenticated user can create member for themselves"
      authorize_if actor_present()
    end

    # Admin-only actions
    policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do
      description "Only admin can manage user-member links"
      authorize_if Mv.Authorization.Checks.HasPermission
    end

    # Standard actions (regular permission check)
    policy action([:read, :update, :destroy]) do
      authorize_if Mv.Authorization.Checks.HasPermission
    end
  end
end

UI Examples

User Self-Service:

<!-- User sees "Create My Profile" if they have no linked member -->
<%= if is_nil(@current_user.member_id) do %>
  <.link navigate="/members/new_for_self">
    Create My Member Profile
  </.link>
<% end %>

<!-- Form for self-service member creation -->
<.simple_form for={@form} phx-submit="create_for_self">
  <.input field={@form[:name]} label="Name" />
  <.input field={@form[:email]} label="Email" />
  <.input field={@form[:address]} label="Address" />
  
  <!-- user_id is NOT in the form - automatically set by action -->
  
  <:actions>
    <.button>Create My Profile</.button>
  </:actions>
</.simple_form>

Admin Interface:

<!-- Admin sees additional actions on member detail page -->
<%= if can?(@current_user, :link_member_to_user, @member) do %>
  <%= if is_nil(@member.user_id) do %>
    <!-- Link member to user -->
    <.form for={@link_form} phx-submit="link_to_user">
      <.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} />
      <.button>Link to User</.button>
    </.form>
  <% else %>
    <!-- Unlink member from user -->
    <.button phx-click="unlink_from_user" phx-value-id={@member.id}>
      Unlink from User (<%= @member.user.email %>)
    </.button>
  <% end %>
<% end %>

Why Separate Actions?

Clear Intent: Action name communicates what's happening
Precise Policies: Different policies for different operations
Better UX: Separate UI flows for self-service vs. admin
Testable: Each action can be tested independently
Idiomatic Ash: Uses Ash's action system as designed


Future: Phase 2 - Field-Level Permissions

Status: Not in MVP, planned for future enhancement

Goal: Control which fields a user can read or write, beyond resource-level permissions.

Strategy

Extend PermissionSets module with :fields key:

def get_permissions(:read_only) do
  %{
    resources: [...],
    pages: [...],
    fields: [
      # Vorstand can read all member fields except sensitive payment info
      %{
        resource: "Member",
        action: :read,
        fields: [:all],
        excluded_fields: [:payment_method, :bank_account]
      },
      
      # Vorstand cannot write any member fields
      %{
        resource: "Member",
        action: :update,
        fields: []  # Empty = no fields writable
      }
    ]
  }
end

Read Filtering via Ash Calculations:

defmodule Mv.Membership.Member do
  calculations do
    calculate :filtered_fields, :map do
      calculate fn members, context ->
        actor = context[:actor]
        
        # Get allowed fields from PermissionSets
        allowed_fields = get_allowed_read_fields(actor, "Member")
        
        # Filter fields
        Enum.map(members, fn member ->
          Map.take(member, allowed_fields)
        end)
      end
    end
  end
end

Write Protection via Custom Validations:

validations do
  validate on: :update do
    validate fn changeset, context ->
      actor = context[:actor]
      changed_fields = Map.keys(changeset.attributes)
      
      # Get allowed fields from PermissionSets
      allowed_fields = get_allowed_write_fields(actor, "Member")
      
      # Check if any forbidden field is being changed
      forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields))
      
      if Enum.empty?(forbidden) do
        :ok
      else
        {:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"}
      end
    end
  end
end

Benefits:

  • No database schema changes
  • Still uses hardcoded PermissionSets
  • Granular control over sensitive fields
  • Clear error messages

Estimated Effort: 2-3 weeks


Future: Phase 3 - Database-Backed Permissions

Status: Not in MVP, planned for future when runtime configuration is needed

Goal: Move permission definitions from code to database for runtime configuration.

High-Level Design

New Tables:

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:

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

  • Hardcoded PermissionSets module
  • HasPermission check reads from module
  • Role table with permission_set_name string
  • Zero DB queries for permission checks

Phase 2: Field-Level (2-3 weeks) - FUTURE

  • Extend PermissionSets with :fields key
  • Ash Calculations for read filtering
  • Custom Validations for write protection
  • No database schema changes

Phase 3: Database-Backed (3-4 weeks) - FUTURE

  • New tables: permission_sets, permission_set_resources, permission_set_pages
  • New HasResourcePermission check queries DB
  • ETS cache for performance
  • Runtime permission configuration

When to Migrate?

Stay with MVP if:

  • 4 permission sets are sufficient
  • Permission changes are rare (quarterly or less)
  • Code deployments for permission changes are acceptable
  • Performance is critical (< 1μs checks)

Migrate to Phase 2 if:

  • Need field-level granularity
  • Different roles need access to different fields
  • Still OK with hardcoded permissions

Migrate to Phase 3 if:

  • Need frequent permission changes
  • Need per-tenant customization
  • Want non-technical users to configure permissions
  • OK with slightly more complex system

Migration from MVP to Phase 3

Step-by-step:

  1. Create DB Tables (1 day)

    • Run migrations for permission_sets, permission_set_resources, permission_set_pages
    • Add indexes
  2. Seed from PermissionSets Module (1 day)

    • Script that reads from PermissionSets.get_permissions/1
    • Inserts into new tables
    • Verify data integrity
  3. Create HasResourcePermission Check (2 days)

    • New check that queries DB
    • Same logic as HasPermission but different data source
    • Comprehensive tests
  4. Implement ETS Cache (2 days)

    • Cache module
    • Cache invalidation on updates
    • Performance tests
  5. Update Policies (3 days)

    • Replace HasPermission with HasResourcePermission in all resources
    • Test each resource thoroughly
  6. Update UI Helpers (1 day)

    • Modify MvWeb.Authorization to query DB
    • Use cache for performance
  7. Update Page Plug (1 day)

    • Modify CheckPagePermission to query DB
    • Use cache
  8. Integration Testing (3 days)

    • Full user journey tests
    • Performance testing
    • Load testing
  9. Deploy to Staging (1 day)

    • Feature flag approach
    • Run both systems in parallel
    • Compare results
  10. Deploy to Production (1 day)

    • Gradual rollout
    • Monitor performance
    • Rollback plan ready
  11. Cleanup (1 day)

    • Remove old HasPermission check
    • Remove PermissionSets module
    • Update documentation

Total: ~3-4 weeks


Security Considerations

Threat Model

Threats Addressed:

  1. Unauthorized Data Access: Policies prevent users from accessing data outside their permissions
  2. Privilege Escalation: Role-based system prevents users from granting themselves higher privileges
  3. UI Tampering: Backend policies enforce authorization even if UI is bypassed
  4. Session Hijacking: Mitigation handled by existing authentication system (not in scope)

Threats NOT Addressed:

  1. SQL Injection: Ash Framework handles query building securely
  2. XSS: Phoenix LiveView handles HTML escaping
  3. CSRF: Phoenix CSRF tokens (existing)

Defense in Depth

Three Layers of Authorization:

  1. Page Access Layer (Plug):

    • Blocks unauthorized page access
    • Runs before LiveView mounts
    • Fast fail for obvious violations
  2. UI Layer (Authorization Helpers):

    • Hides buttons/links user can't use
    • Prevents confusing "forbidden" errors
    • Improves UX
  3. Resource Layer (Ash Policies):

    • Primary enforcement point
    • Cannot be bypassed
    • Filters queries automatically

Even if attacker:

  • Tampers with UI → Backend policies still enforce
  • Calls API directly → Policies apply
  • Modifies page JavaScript → Policies apply

Authorization Best Practices

DO:

  • Always preload :role relationship for actor
  • Log authorization failures for debugging
  • Use explicit policies (no implicit allow)
  • Test policies with all role types
  • Test special cases (nil role, invalid permission_set_name)

DON'T:

  • Trust UI-level checks alone
  • Skip policy checks for "admin"
  • Use bypass or skip_authorization in production
  • Expose raw permission logic in API responses

Audit Logging (Future)

Not in MVP, but planned:

defmodule Mv.Authorization.AuditLog do
  def log_authorization_failure(actor, resource, action, reason) do
    Ash.create!(AuditLog, %{
      user_id: actor.id,
      resource: inspect(resource),
      action: action,
      outcome: "forbidden",
      reason: reason,
      ip_address: get_ip_address(),
      timestamp: DateTime.utc_now()
    })
  end
end

Benefits:

  • Track suspicious authorization attempts
  • Compliance (GDPR requires access logs)
  • Debugging production issues

Appendix

Glossary

  • Permission Set: Named collection of permissions (e.g., "admin", "read_only")
  • Role: Database entity linking users to permission sets
  • Scope: Range of records permission applies to (:own, :linked, :all)
  • Actor: Currently authenticated user in Ash context
  • Policy: Ash authorization rule on a resource
  • System Role: Role that cannot be deleted (is_system_role=true)
  • Special Case: Authorization rule that takes precedence over general permissions

Resource Name Mapping

The HasPermission check extracts resource names via Module.split() |> List.last():

Ash Module Resource Name (String)
Mv.Accounts.User "User"
Mv.Membership.Member "Member"
Mv.Membership.Property "Property"
Mv.Membership.PropertyType "PropertyType"
Mv.Authorization.Role "Role"

These strings must match exactly in PermissionSets module.

Permission Set Summary

Permission Set Typical Roles Key Characteristics
own_data Mitglied Can only access own data and linked member
read_only Vorstand, Buchhaltung Read all data, no modifications
normal_user Kassenwart Create/Read/Update members (no delete), full CRUD on properties, no admin
admin Admin Unrestricted access, wildcard pages

Edge Case Reference

Edge Case Behavior Implementation
User without role Access denied everywhere Seeds assign default role, runtime checks handle gracefully
Invalid permission_set_name Access denied Validation on Role, runtime safety checks
System role deletion Forbidden Validation prevents deletion if is_system_role=true
Linked member email Admin-only edit Custom validation in Member resource
Own credentials Always accessible Special policy before general check

Testing Checklist

For Each Resource:

  • All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)
  • All actions tested (read, create, update, destroy)
  • All scopes tested (own, linked, all)
  • Special cases tested
  • Edge cases tested (nil role, invalid permission_set_name)

For UI:

  • Buttons/links show/hide correctly per role
  • Page access controlled per role
  • No broken links (all visible links are accessible)

Integration:

  • One complete user journey per role
  • Cross-resource scenarios (e.g., Member -> Property)
  • Special cases in context (e.g., linked member email during full edit flow)

Useful Commands

# Run all authorization tests
mix test test/mv/authorization

# Run integration tests
mix test test/integration

# Run with coverage
mix test --cover

# Generate migrations
mix ash.codegen

# Run seeds
mix run priv/repo/seeds/authorization_seeds.exs

# Check permission for user in IEx
iex> user = Mv.Accounts.get_user!("user-id")
iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member)

# Check page access in IEx
iex> MvWeb.Authorization.can_access_page?(user, "/members/new")

Document Version: 2.0 (Clean Rewrite)
Last Updated: 2025-01-13
Status: Ready for Implementation

Changes from V1:

  • Complete rewrite focused on MVP (hardcoded permissions)
  • Removed all database-backed permission details from MVP sections
  • Unified naming (HasPermission for MVP)
  • Added Role resource policies
  • Clarified resource-specific :linked scope
  • Moved Phase 2 and Phase 3 to clearly marked "Future" sections
  • Fixed Buchhaltung inconsistency (read_only everywhere)
  • Added comprehensive security section
  • Enhanced edge case documentation

End of Architecture Document