mitgliederverwaltung/docs/roles-and-permissions-architecture.md
Moritz 1084f67f1f
docs: Add roles and permissions architecture and implementation plan
Complete RBAC system design with permission sets, Ash policies, and UI authorization.
Implementation broken down into 18 issues across 4 sprints with TDD approach.
Includes database schema, caching strategy, and comprehensive test coverage.
2025-11-13 13:43:58 +01:00

66 KiB

Roles and Permissions Architecture

Project: Mila - Membership Management System
Feature: Role-Based Access Control (RBAC) with Permission Sets
Version: 1.0
Last Updated: 2025-11-10
Status: Architecture Design


Table of Contents

  1. Overview
  2. Requirements Analysis
  3. Evaluated Approaches
  4. Selected Architecture
  5. Database Schema
  6. Permission System Design
  7. Implementation Details
  8. Future Extensions
  9. Migration Strategy
  10. Security Considerations

Overview

This document describes the architecture for implementing a flexible, scalable role-based access control (RBAC) system for the Mila membership management application. The system provides:

  • Predefined Permission Sets with configurable permissions
  • Dynamic Roles that reference permission sets
  • Resource-level and Action-level authorization
  • Page-level access control for LiveView routes
  • Special handling for credentials and linked user-member relationships
  • Future extensibility for field-level permissions

Key Design Principles

  1. Separation of Concerns: Permission Sets (what you can do) vs. Roles (job titles/functions)
  2. Flexibility: Admins can configure permissions at runtime via database
  3. Performance: Leverage Ash Framework policies with ETS caching
  4. Extensibility: Architecture supports future field-level granularity
  5. Consistency: Single unified permission model for all resources

Requirements Analysis

Core Requirements

Based on the project requirements, the system must support:

Permission Sets (4 Predefined)

  1. Own Data - Users can only access their own data
  2. Read-Only - Read access to all members, groups, and custom fields
  3. Normal User - Read and write access to members and custom fields
  4. Admin - Full access to all resources including user management

Example Roles

  • Mitglied (Member) - Default role, own data access
  • Vorstand (Board) - Access to members, not users
  • Kassenwart (Treasurer) - Access to payment information
  • Buchhaltung (Accounting) - Read-only access
  • Admin - Full administrative access

Authorization Granularity

Resource Level (Phase 1 - Now):

  • Member: read, create, update, destroy
  • User: read, create, update, destroy
  • PropertyType: read, create, update, destroy
  • Property: read, create, update, destroy
  • Role: read, create, update, destroy
  • Payment (future): read, create, update, destroy

Page Level:

  • Control access to LiveView pages
  • Pages are read-only access checks
  • Edit pages require both page access AND resource write permission

Field Level (Phase 2 - Later):

  • Restrict read/write access to specific member fields
  • Restrict access to specific custom field types
  • Example: Treasurer sees payment_history, Board does not

Special Cases

  1. User Credentials:

    • Users can ALWAYS edit their own credentials (email, password)
    • Only Admins can edit OTHER users' credentials
    • Email field of members linked to users can only be edited by Admins
  2. Required Fields:

    • When creating a member with required custom fields, user must be able to write those fields
    • Even if user normally doesn't have write permission for that field type
    • After creation, normal permissions apply
  3. Payment History (Future):

    • Configurable per permission set
    • Members may or may not see their own payment history
  4. Linked User-Member Relationships:

    • Member email sync follows special rules
    • User email is source of truth for linked members

Constraints

  • Roles can be added, renamed, or removed by admins
  • Permission sets are predefined but permissions are configurable
  • Each user has exactly ONE role
  • Roles cannot overlap (no multiple role assignment per user)
  • "Mitglied" role is a system role and cannot be deleted
  • Permission sets are system-defined and cannot be deleted

Evaluated Approaches

We evaluated four different architectural approaches for implementing the authorization system:

Approach 1: Ash Policies + RBAC with JSONB Permissions

Description: Store permissions as JSONB in the Role resource, use custom Ash Policy checks to evaluate them.

Database Structure:

roles (id, name, permissions_config: jsonb)
users (role_id)

Permissions stored as:

{
  "resource_permissions": {
    "Member": {"read": true, "update": false}
  },
  "page_permissions": {
    "/members": true
  }
}

Advantages:

  • Simple database schema (fewer tables)
  • Flexible JSON structure
  • Fast schema changes (no migrations needed)
  • Easy to serialize/deserialize

Disadvantages:

  • No referential integrity on permission keys
  • JSONB queries are less efficient than normalized tables
  • Difficult to query "which roles have access to X?"
  • Schema validation happens in application code
  • No indexing on individual permissions
  • Versioning of JSONB structure becomes complex

Verdict: Not selected - JSONB makes querying and validation difficult


Approach 2: Hybrid RBAC + ABAC with Permission Matrix

Description: Separate tables for every permission type with full granularity from day one.

Database Structure:

roles (id, name)
permissions (id, resource, action, field, condition)
role_permissions (role_id, permission_id, granted)
user_roles (user_id, role_id)

Advantages:

  • Maximum flexibility
  • Highly granular from the start
  • Easy to add new permission types
  • Audit trail built-in

Disadvantages:

  • Very complex database schema
  • High JOIN overhead on every authorization check
  • Over-engineered for current requirements
  • Difficult to cache effectively
  • Performance concerns with many permissions
  • Complex to seed and maintain

Verdict: Not selected - Too complex for current needs, over-engineering


Approach 3: Policy Graphs with Custom Authorizer

Description: Use Ash Policies for action-level checks, custom Authorizer module for field-level filtering.

Database Structure:

roles (id, name, permission_config)
Custom Authorizer reads config and applies filters

Advantages:

  • Best performance (optimized for Ash)
  • Granular field-level control
  • Can leverage Ash query optimization

Disadvantages:

  • Requires custom authorizer implementation (non-standard)
  • More code to maintain
  • Harder to test than declarative policies
  • Mixes declarative (Policies) and imperative (Authorizer) approaches

Verdict: Not selected - Too much custom code, reduces maintainability


Approach 4: Simple Role Enum (Quick Start)

Description: Simple :role field on User with enum values, policies hardcoded in resources.

Database Structure:

users (role: :admin | :vorstand | :kassenwart | :member)

Advantages:

  • Very simple to implement (1 week)
  • No extra tables needed
  • Fast performance
  • Easy to understand

Disadvantages:

  • No dynamic permission configuration
  • Requires code deployment to change permissions
  • Can't add new roles without code changes
  • Not extensible to field-level permissions
  • Doesn't meet requirement for "configurable permissions"

Verdict: Not selected - Doesn't meet core requirements


Selected Architecture

Approach 5: Permission Sets + Normalized Tables (Selected)

Description: Hybrid approach that separates Permission Sets (what you can do) from Roles (who you are), with normalized database tables for queryability and Ash Policies for enforcement.

Key Innovation: Introduce Permission Sets as an abstraction layer between Roles and actual Permissions.

Permission Set (4 predefined, defines capabilities)
    ↓
Role (many, references one Permission Set)
    ↓
User (each has one Role)

Why This Approach?

  1. Meets Requirements:

    • Configurable permissions (stored in database)
    • Dynamic role creation
    • Extensible to field-level
    • Admin UI can modify at runtime
  2. Performance:

    • Normalized tables allow efficient queries
    • Indexes on resource_name and action
    • ETS cache for permission lookups
    • Ash Policies translate to SQL filters
  3. Maintainability:

    • Clear separation of concerns
    • Standard Ash patterns (not custom authorizer)
    • Testable with standard Ash policy tests
    • Easy to understand and debug
  4. Extensibility:

    • field_name column reserved for Phase 2
    • scope system handles "own" vs "all" vs "linked"
    • New resources just add permission rows
    • No code changes needed for new roles
  5. Flexibility:

    • Permission Sets ensure consistency
    • Roles can be renamed without changing permissions
    • Multiple roles can share same permission set
    • Admin can configure at runtime

Trade-offs Accepted:

  • More tables than JSONB approach (but better queryability)
  • More rows than enum approach (but runtime configurable)
  • Not as granular as full ABAC (but simpler to manage)

Database Schema

Entity Relationship Diagram

┌─────────────────────┐
│  permission_sets    │
│─────────────────────│
│ id (PK)            │
│ name               │◄───────┐
│ description        │        │
│ is_system          │        │
└─────────────────────┘        │
                               │
                               │
┌─────────────────────────────┐│
│ permission_set_resources    ││
│─────────────────────────────││
│ id (PK)                    ││
│ permission_set_id (FK)     │┘
│ resource_name              │
│ action                     │
│ scope                      │
│ field_name (nullable)      │
│ granted                    │
└─────────────────────────────┘

┌─────────────────────────────┐
│ permission_set_pages        │
│─────────────────────────────│
│ id (PK)                    │
│ permission_set_id (FK)     │───┐
│ page_path                  │   │
└─────────────────────────────┘   │
                                  │
                                  │
┌─────────────────────┐          │
│  roles              │          │
│─────────────────────│          │
│ id (PK)            │          │
│ name               │          │
│ description        │          │
│ permission_set_id  │──────────┘
│ is_system_role     │
└─────────────────────┘
         ▲
         │
         │
┌─────────────────────┐
│  users              │
│─────────────────────│
│ id (PK)            │
│ email              │
│ hashed_password    │
│ oidc_id            │
│ member_id (FK)     │
│ role_id (FK)       │◄──── Default: "Mitglied" role
└─────────────────────┘

Table Definitions

permission_sets

Defines the 4 core permission sets. These are system-defined and cannot be deleted.

CREATE TABLE permission_sets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL UNIQUE,
  description TEXT,
  is_system BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP NOT NULL DEFAULT now(),
  updated_at TIMESTAMP NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_permission_sets_name ON permission_sets(name);

Records:

  • own_data - Users can only access their own data
  • read_only - Read access to all members and custom fields
  • normal_user - Read and write access to members and custom fields
  • admin - Full access to everything

permission_set_resources

Defines what actions each permission set can perform on which resources.

CREATE TABLE permission_set_resources (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE,
  resource_name VARCHAR(255) NOT NULL,  -- "Member", "User", "PropertyType", etc.
  action VARCHAR(50) NOT NULL,          -- "read", "create", "update", "destroy"
  scope VARCHAR(50),                    -- NULL/"all", "own", "linked"
  field_name VARCHAR(255),              -- NULL = all fields, else specific field (Phase 2)
  granted BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP NOT NULL DEFAULT now(),
  updated_at TIMESTAMP NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_psr_permission_set ON permission_set_resources(permission_set_id);
CREATE INDEX idx_psr_resource_action ON permission_set_resources(resource_name, action);
CREATE UNIQUE INDEX idx_psr_unique ON permission_set_resources(
  permission_set_id, resource_name, action, 
  COALESCE(scope, 'all'), COALESCE(field_name, '')
);

Scope Values:

  • NULL or "all" - Permission applies to all entities of this resource
  • "own" - Permission applies only to user's own data (user.id == actor.id)
  • "linked" - Permission applies only to entities linked to user (e.g., member.user_id == actor.id)

Field Name (Phase 2):

  • NULL - Permission applies to all fields (Phase 1 default)
  • "field_name" - Permission applies only to specific field (Phase 2)

Example Records:

-- Own Data Permission Set: User can read their own User record
INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted)
VALUES (own_data_id, 'User', 'read', 'own', true);

-- Read-Only Permission Set: Can read all Members
INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted)
VALUES (read_only_id, 'Member', 'read', 'all', true);

-- Normal User Permission Set: Can update all Members
INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted)
VALUES (normal_user_id, 'Member', 'update', 'all', true);

-- Admin Permission Set: Can destroy all Members
INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted)
VALUES (admin_id, 'Member', 'destroy', 'all', true);

permission_set_pages

Defines which LiveView pages each permission set can access.

CREATE TABLE permission_set_pages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE,
  page_path VARCHAR(255) NOT NULL,  -- "/members", "/members/:id/edit", "/admin"
  created_at TIMESTAMP NOT NULL DEFAULT now(),
  updated_at TIMESTAMP NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_psp_permission_set ON permission_set_pages(permission_set_id);
CREATE INDEX idx_psp_page_path ON permission_set_pages(page_path);
CREATE UNIQUE INDEX idx_psp_unique ON permission_set_pages(permission_set_id, page_path);

Page Paths:

  • Static paths: /members, /users, /admin
  • Dynamic paths: /members/:id, /members/:id/edit
  • Must match Phoenix Router routes exactly

Important: Page permissions are READ-ONLY access checks. If a user shouldn't access an edit page, they don't get the page permission. The actual write operation is controlled by resource permissions.

Example Records:

-- Own Data: Only profile page
INSERT INTO permission_set_pages (permission_set_id, page_path)
VALUES (own_data_id, '/profile');

-- Read-Only: Member index and show pages
INSERT INTO permission_set_pages (permission_set_id, page_path)
VALUES 
  (read_only_id, '/members'),
  (read_only_id, '/members/:id');

-- Normal User: Member pages including edit
INSERT INTO permission_set_pages (permission_set_id, page_path)
VALUES 
  (normal_user_id, '/members'),
  (normal_user_id, '/members/new'),
  (normal_user_id, '/members/:id'),
  (normal_user_id, '/members/:id/edit');

-- Admin: All pages
INSERT INTO permission_set_pages (permission_set_id, page_path)
VALUES 
  (admin_id, '/members'),
  (admin_id, '/members/new'),
  (admin_id, '/members/:id'),
  (admin_id, '/members/:id/edit'),
  (admin_id, '/users'),
  (admin_id, '/users/new'),
  (admin_id, '/users/:id'),
  (admin_id, '/users/:id/edit'),
  (admin_id, '/admin');

roles

Defines user roles that reference one permission set each.

CREATE TABLE roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL UNIQUE,
  description TEXT,
  permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE RESTRICT,
  is_system_role BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP NOT NULL DEFAULT now(),
  updated_at TIMESTAMP NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_roles_name ON roles(name);
CREATE INDEX idx_roles_permission_set ON roles(permission_set_id);

System Roles:

  • is_system_role = true for "Mitglied" (default role)
  • System roles cannot be deleted
  • Can be renamed but must always exist

Example Records:

-- Mitglied (default role for all users)
INSERT INTO roles (name, description, permission_set_id, is_system_role)
VALUES ('Mitglied', 'Standard role for all members', own_data_id, true);

-- Vorstand (board member with read access)
INSERT INTO roles (name, description, permission_set_id, is_system_role)
VALUES ('Vorstand', 'Board member with read access to all members', read_only_id, false);

-- Kassenwart (treasurer with write access + payment info)
INSERT INTO roles (name, description, permission_set_id, is_system_role)
VALUES ('Kassenwart', 'Treasurer with access to payment information', normal_user_id, false);

-- Buchhaltung (accounting with read access)
INSERT INTO roles (name, description, permission_set_id, is_system_role)
VALUES ('Buchhaltung', 'Accounting with read-only access', read_only_id, false);

-- Admin (full access)
INSERT INTO roles (name, description, permission_set_id, is_system_role)
VALUES ('Admin', 'Full administrative access', admin_id, false);

users (Extended)

Add role_id foreign key to existing users table.

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

-- Set default to "Mitglied" role (via migration)
UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL;

ALTER TABLE users ALTER COLUMN role_id SET NOT NULL;

-- Index
CREATE INDEX idx_users_role ON users(role_id);

Permission System Design

Permission Evaluation Flow

Request comes in (LiveView mount or Ash action)
    ↓
1. Load Current User with Role preloaded
    ↓
2. Check Page Permission (if LiveView)
   - Query: permission_set_pages WHERE page_path = current_path
   - If no match: DENY, redirect to "/"
    ↓
3. Ash Policy Check (for resource actions)
   - Policy 1: Check "relates_to_actor" (own data)
   - Policy 2: Check custom permission via DB
       - Load permission_set_resources
       - Match: resource_name, action, scope
       - Evaluate scope:
           * "own" → Filter: id == actor.id
           * "linked" → Filter: user_id == actor.id
           * "all" → No filter
   - Policy 3: Default DENY
    ↓
4. Special Validations (if applicable)
   - Member email change on linked member
   - Required fields on create
    ↓
5. Execute Action or Render Page

Scope Evaluation

The scope field determines which subset of records a permission applies to:

Scope: "own"

Used for resources where user has direct ownership.

Applicable to: User

Filter Logic:

{:filter, expr(id == ^actor.id)}

Example:

  • Own Data permission set has User.read with scope "own"
  • User can only read their own User record
  • Query becomes: SELECT * FROM users WHERE id = $actor_id

Scope: "linked"

Used for resources linked to user via intermediate relationship.

Applicable to: Member, Property, Payment (future)

Filter Logic:

# For Member
{:filter, expr(user_id == ^actor.id)}

# For Property (traverses relationship)
{:filter, expr(member.user_id == ^actor.id)}

# For Payment (future, traverses relationship)
{:filter, expr(member.user_id == ^actor.id)}

Example:

  • Own Data permission set has Member.read with scope "linked"
  • User can only read Members linked to them (member.user_id == actor.id)
  • If user has no linked member: no results
  • Query becomes: SELECT * FROM members WHERE user_id = $actor_id

Scope: "all" or NULL

Used for full access to all records of a resource.

Applicable to: All resources

Filter Logic:

:authorized  # No filter, all records allowed

Example:

  • Read-Only permission set has Member.read with scope "all"
  • User can read all Members
  • Query becomes: SELECT * FROM members (no WHERE clause for authorization)

Policy Implementation in Ash Resources

Each Ash resource defines policies that check permissions:

defmodule Mv.Membership.Member do
  use Ash.Resource, ...
  
  policies do
    # Policy 1: Users can always access their own linked member data
    # This bypasses permission checks for own data
    policy action_type([:read, :update]) do
      description "Users can always access their own member data if linked"
      authorize_if relates_to_actor_via(:user)
    end
    
    # Policy 2: Check database permissions
    # This is where permission_set_resources table is queried
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check if actor's role has permission for this action"
      authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action()
    end
    
    # Policy 3: Default deny
    # If no policy matched, forbid access
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
end

Important: Policy order matters! First matching policy wins.


Custom Policy Check Implementation

defmodule Mv.Authorization.Checks.HasResourcePermission do
  @moduledoc """
  Custom Ash Policy Check that evaluates database-stored permissions.
  
  Queries the permission_set_resources table based on actor's role
  and evaluates scope to return appropriate filter.
  """
  
  use Ash.Policy.Check
  
  @impl true
  def type, do: :filter
  
  @impl true
  def match?(actor, context, _opts) do
    resource = context.resource
    action = context.action
    
    # Load actor's permission set (with caching)
    case get_permission_set(actor) do
      nil -> 
        :forbidden
      
      permission_set ->
        # Query permission_set_resources table
        check_permission(permission_set.id, resource, action.name, actor, context)
    end
  end
  
  defp get_permission_set(nil), do: nil
  defp get_permission_set(actor) do
    # Try cache first (ETS)
    case Mv.Authorization.PermissionCache.get_permission_set(actor.id) do
      {:ok, permission_set} -> 
        permission_set
      
      :miss ->
        # Load from database: user → role → permission_set
        load_and_cache_permission_set(actor)
    end
  end
  
  defp load_and_cache_permission_set(actor) do
    case Ash.load(actor, role: :permission_set) do
      {:ok, user_with_relations} ->
        permission_set = user_with_relations.role.permission_set
        Mv.Authorization.PermissionCache.put_permission_set(actor.id, permission_set)
        permission_set
      
      _ -> 
        nil
    end
  end
  
  defp check_permission(permission_set_id, resource, action, actor, context) do
    resource_name = resource |> Module.split() |> List.last()
    
    # Query permission_set_resources
    query = 
      Mv.Authorization.PermissionSetResource
      |> Ash.Query.filter(
        permission_set_id == ^permission_set_id and
        resource_name == ^resource_name and
        action == ^action and
        is_nil(field_name)  # Phase 1: only resource-level
      )
    
    case Ash.read_one(query) do
      {:ok, permission} ->
        evaluate_permission(permission, actor, context)
      
      _ -> 
        :forbidden
    end
  end
  
  defp evaluate_permission(%{granted: false}, _actor, _context) do
    :forbidden
  end
  
  defp evaluate_permission(%{granted: true, scope: nil}, _actor, _context) do
    :authorized
  end
  
  defp evaluate_permission(%{granted: true, scope: "all"}, _actor, _context) do
    :authorized
  end
  
  defp evaluate_permission(%{granted: true, scope: "own"}, actor, _context) do
    # Return filter expression for Ash
    {:filter, expr(id == ^actor.id)}
  end
  
  defp evaluate_permission(%{granted: true, scope: "linked"}, actor, context) do
    resource = context.resource
    
    # Generate appropriate filter based on resource
    case resource do
      Mv.Membership.Member ->
        {:filter, expr(user_id == ^actor.id)}
      
      Mv.Membership.Property ->
        {:filter, expr(member.user_id == ^actor.id)}
      
      # Add more resources as needed
      
      _ ->
        :forbidden
    end
  end
end

Implementation Details

Phase 1: Resource and Page Level Permissions

Timeline: Sprint 1-2 (2-3 weeks)

Deliverables:

  1. Database migrations for all permission tables
  2. Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage
  3. Custom policy checks
  4. Permission cache (ETS)
  5. Router plug for page permissions
  6. Seeds for 4 permission sets and 5 roles
  7. Admin UI for role management
  8. Tests for all permission scenarios

Not Included in Phase 1:

  • Field-level permissions (field_name is always NULL)
  • Payment history (resource doesn't exist yet)
  • Groups (not yet planned)

Special Cases Implementation

1. User Credentials - Always Editable by Owner

Requirement: Users can always edit their own email and password, regardless of permission set.

Implementation:

defmodule Mv.Accounts.User do
  policies do
    # Policy 1: Users can ALWAYS read and update their own credentials
    # This comes BEFORE permission checks
    policy action_type([:read, :update]) do
      description "Users can always access and update their own credentials"
      authorize_if expr(id == ^actor(:id))
    end
    
    # Policy 2: Check permission set (for admins accessing other users)
    policy action_type([:read, :create, :update, :destroy]) do
      authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action()
    end
    
    # Policy 3: Default deny
    policy action_type([:read, :create, :update, :destroy]) do
      forbid_if always()
    end
  end
end

Result:

  • Mitglied role: Can edit own User record (own email/password)
  • Admin role: Can edit ANY User record (including others' credentials)
  • Other roles: Cannot access User resource unless specifically granted

2. Member Email for Linked Members - Admin Only

Requirement: If a member is linked to a user, only admins can edit the member's email field.

Implementation:

defmodule Mv.Membership.Member do
  validations do
    validate fn changeset, context ->
      # Only check if email is being changed
      if Ash.Changeset.changing_attribute?(changeset, :email) do
        member = changeset.data
        actor = context.actor
        
        # Load member's user relationship
        case Ash.load(member, :user) do
          {:ok, %{user: %{id: _user_id}}} ->
            # Member IS linked to a user
            # Check if actor has permission to edit ALL users
            if has_permission_for_all_users?(actor) do
              :ok
            else
              {:error, 
               field: :email,
               message: "Only admins can edit email of members linked to users"}
            end
          
          {:ok, %{user: nil}} ->
            # Member is NOT linked
            # Normal Member.update permission applies
            :ok
          
          {:error, _} ->
            :ok
        end
      else
        :ok
      end
    end
  end
  
  defp has_permission_for_all_users?(actor) do
    # Check if actor's permission set has User.update with scope="all"
    permission_set = get_permission_set(actor)
    
    Mv.Authorization.PermissionSetResource
    |> Ash.Query.filter(
      permission_set_id == ^permission_set.id and
      resource_name == "User" and
      action == "update" and
      scope == "all" and
      granted == true
    )
    |> Ash.exists?()
  end
end

Result:

  • Admin: Can edit email of any member (including linked ones)
  • Normal User/Read-Only: Can edit email of unlinked members only
  • Attempting to edit email of linked member without permission: Validation error

3. Required Custom Fields on Member Creation

Requirement: When creating a member with required custom fields, user must be able to set those fields even if they normally don't have permission.

Implementation:

For Phase 1, this is not an issue because:

  • PropertyType.required flag exists but isn't enforced yet
  • No field-level permissions exist yet
  • If user has Member.create permission, they can set Properties

For Phase 2 (when field-level permissions exist):

defmodule Mv.Membership.Property do
  actions do
    create :create_property do
      # Special handling for required properties during member creation
      change Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate
    end
  end
end

defmodule Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate do
  use Ash.Resource.Change
  
  def change(changeset, _opts, context) do
    # Check if this is part of a member creation
    if creating_member?(context) do
      property_type_id = Ash.Changeset.get_attribute(changeset, :property_type_id)
      
      # Load PropertyType
      case Ash.get(Mv.Membership.PropertyType, property_type_id) do
        {:ok, %{required: true}} ->
          # This is a required field, allow creation even without normal permission
          # Set special context flag
          Ash.Changeset.set_context(changeset, :bypass_property_permission, true)
        
        _ ->
          changeset
      end
    else
      changeset
    end
  end
end

Page Permission Implementation

Router Configuration:

defmodule MvWeb.Router do
  use MvWeb, :router
  import MvWeb.Authorization
  
  # Pipeline with permission check
  pipeline :require_page_permission do
    plug :put_secure_browser_headers
    plug :fetch_current_user
    plug MvWeb.Plugs.CheckPagePermission
  end
  
  scope "/", MvWeb do
    pipe_through [:browser, :require_authenticated_user, :require_page_permission]
    
    # These routes automatically check page permissions
    live "/members", MemberLive.Index, :index
    live "/members/new", MemberLive.Index, :new
    live "/members/:id", MemberLive.Show, :show
    live "/members/:id/edit", MemberLive.Index, :edit
    
    live "/users", UserLive.Index, :index
    live "/users/new", UserLive.Index, :new
    live "/users/:id", UserLive.Show, :show
    live "/users/:id/edit", UserLive.Index, :edit
    
    live "/property-types", PropertyTypeLive.Index, :index
    live "/property-types/new", PropertyTypeLive.Index, :new
    live "/property-types/:id/edit", PropertyTypeLive.Index, :edit
    
    live "/admin", AdminLive.Dashboard, :index
  end
end

Page Permission Plug:

defmodule MvWeb.Plugs.CheckPagePermission do
  @moduledoc """
  Plug that checks if current user has permission to access the current page.
  
  Queries permission_set_pages table based on user's role → permission_set.
  """
  
  import Plug.Conn
  import Phoenix.Controller
  
  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
      conn
      |> put_flash(:error, "You don't have permission to access this page.")
      |> redirect(to: "/")
      |> halt()
    end
  end
  
  defp get_page_path(conn) do
    # Extract route template from conn
    # "/members/:id/edit" from actual "/members/123/edit"
    case conn.private[:phoenix_route] do
      {_, _, _, route_template, _} -> route_template
      _ -> conn.request_path
    end
  end
  
  defp has_page_permission?(nil, _page_path), do: false
  defp has_page_permission?(user, page_path) do
    # Try cache first
    case Mv.Authorization.PermissionCache.get_page_permission(user.id, page_path) do
      {:ok, has_permission} -> 
        has_permission
      
      :miss ->
        # Load from database and cache
        has_permission = check_page_permission_db(user, page_path)
        Mv.Authorization.PermissionCache.put_page_permission(user.id, page_path, has_permission)
        has_permission
    end
  end
  
  defp check_page_permission_db(user, page_path) do
    # Load user → role → permission_set
    case Ash.load(user, role: :permission_set) do
      {:ok, user_with_relations} ->
        permission_set_id = user_with_relations.role.permission_set.id
        
        # Check if permission_set_pages has this page
        Mv.Authorization.PermissionSetPage
        |> Ash.Query.filter(
          permission_set_id == ^permission_set_id and
          page_path == ^page_path
        )
        |> Ash.exists?()
      
      _ -> 
        false
    end
  end
end

Important: Both page permission AND resource permission must be true for edit operations:

  • User needs /members/:id/edit page permission to see the page
  • User needs Member.update permission to actually save changes
  • If user has page permission but not resource permission: page loads but save fails

Permission Cache Implementation

ETS Cache for Performance:

defmodule Mv.Authorization.PermissionCache do
  @moduledoc """
  ETS-based cache for user permissions to avoid database lookups on every request.
  
  Cache stores:
  - User's permission_set (user_id → permission_set)
  - Page permissions (user_id + page_path → boolean)
  - Resource permissions (user_id + resource + action → permission)
  
  Cache is invalidated when:
  - User's role changes
  - Role's permission_set changes
  - Permission set's permissions change
  """
  
  use GenServer
  
  @table_name :permission_cache
  
  # Client API
  
  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end
  
  def get_permission_set(user_id) do
    case :ets.lookup(@table_name, {:permission_set, user_id}) do
      [{_, permission_set}] -> {:ok, permission_set}
      [] -> :miss
    end
  end
  
  def put_permission_set(user_id, permission_set) do
    :ets.insert(@table_name, {{:permission_set, user_id}, permission_set})
    :ok
  end
  
  def get_page_permission(user_id, page_path) do
    case :ets.lookup(@table_name, {:page, user_id, page_path}) do
      [{_, has_permission}] -> {:ok, has_permission}
      [] -> :miss
    end
  end
  
  def put_page_permission(user_id, page_path, has_permission) do
    :ets.insert(@table_name, {{:page, user_id, page_path}, has_permission})
    :ok
  end
  
  def invalidate_user(user_id) do
    # Delete all entries for this user
    :ets.match_delete(@table_name, {{:permission_set, user_id}, :_})
    :ets.match_delete(@table_name, {{:page, user_id, :_}, :_})
    :ok
  end
  
  def invalidate_all do
    :ets.delete_all_objects(@table_name)
    :ok
  end
  
  # Server Callbacks
  
  def init(_) do
    table = :ets.new(@table_name, [
      :set,
      :public,
      :named_table,
      read_concurrency: true,
      write_concurrency: true
    ])
    {:ok, %{table: table}}
  end
end

Cache Invalidation Strategy:

defmodule Mv.Authorization.Role do
  # After updating role's permission_set
  changes do
    change after_action(fn changeset, role, _context ->
      # Invalidate cache for all users with this role
      invalidate_users_with_role(role.id)
      {:ok, role}
    end), on: [:update]
  end
  
  defp invalidate_users_with_role(role_id) do
    # Find all users with this role
    users = 
      Mv.Accounts.User
      |> Ash.Query.filter(role_id == ^role_id)
      |> Ash.read!()
    
    # Invalidate each user's cache
    Enum.each(users, fn user ->
      Mv.Authorization.PermissionCache.invalidate_user(user.id)
    end)
  end
end

defmodule Mv.Authorization.PermissionSetResource do
  # After updating permissions
  changes do
    change after_action(fn changeset, permission, _context ->
      # Invalidate all users with this permission set
      invalidate_permission_set(permission.permission_set_id)
      {:ok, permission}
    end), on: [:create, :update, :destroy]
  end
  
  defp invalidate_permission_set(permission_set_id) do
    # Find all roles with this permission set
    roles = 
      Mv.Authorization.Role
      |> Ash.Query.filter(permission_set_id == ^permission_set_id)
      |> Ash.read!()
    
    # Invalidate all users with these roles
    Enum.each(roles, fn role ->
      invalidate_users_with_role(role.id)
    end)
  end
end

UI-Level Authorization

Requirement: The user interface should only display links, buttons, and fields that the user has permission to access. This improves UX and prevents confusion.

Key Principles:

  1. Navigation Links: Hide links to pages the user cannot access
  2. Action Buttons: Hide "Edit", "Delete", "New" buttons when user lacks permissions
  3. Form Fields: In Phase 2, hide fields the user cannot read/write
  4. Proactive UI: Never show a clickable element that would result in "Forbidden"

Implementation Approach

Helper Module: MvWeb.Authorization

defmodule MvWeb.Authorization do
  @moduledoc """
  UI-level authorization helpers for LiveView.
  
  These helpers check permissions and determine what UI elements to show.
  They work in conjunction with Ash Policies (which are the actual enforcement).
  """
  
  alias Mv.Authorization.PermissionCache
  alias Mv.Authorization
  
  @doc """
  Checks if actor can perform action on resource.
  
  ## Examples
  
      # In LiveView template
      <%= if can?(@current_user, :update, Mv.Membership.Member) do %>
        <button>Edit Member</button>
      <% end %>
      
      # In LiveView module
      if can?(socket.assigns.current_user, :create, Mv.Membership.PropertyType) do
        # Show "New Custom Field" button
      end
  """
  def can?(nil, _action, _resource), do: false
  
  def can?(user, action, resource) when is_atom(action) and is_atom(resource) do
    resource_name = resource_name(resource)
    
    # Check cache first
    case get_permission_from_cache(user.id, resource_name, action) do
      {:ok, result} -> result
      :miss -> check_permission_from_db(user, resource_name, action)
    end
  end
  
  @doc """
  Checks if actor can access a specific page path.
  
  ## Examples
  
      # In navigation component
      <%= if can_access_page?(@current_user, "/members") do %>
        <.link navigate="/members">Members</.link>
      <% end %>
  """
  def can_access_page?(nil, _page_path), do: false
  
  def can_access_page?(user, page_path) do
    # Check cache first
    case PermissionCache.get_page_permission(user.id, page_path) do
      {:ok, result} -> result
      :miss -> check_page_permission_from_db(user, page_path)
    end
  end
  
  @doc """
  Checks if actor can perform action on a specific record.
  
  This respects scope restrictions (own, linked, all).
  
  ## Examples
  
      # Show edit button only if user can edit THIS member
      <%= if can?(@current_user, :update, member) do %>
        <button>Edit</button>
      <% end %>
  """
  def can?(nil, _action, _record), do: false
  
  def can?(user, action, %resource{} = record) when is_atom(action) do
    resource_name = resource_name(resource)
    
    # First check if user has any permission for this action
    case get_permission_from_cache(user.id, resource_name, action) do
      {:ok, false} -> 
        false
      
      {:ok, true} -> 
        # User has permission, now check scope
        check_scope_for_record(user, action, resource, record)
      
      :miss -> 
        check_permission_and_scope_from_db(user, action, resource, record)
    end
  end
  
  # Private helpers
  
  defp resource_name(Mv.Accounts.User), do: "User"
  defp resource_name(Mv.Membership.Member), do: "Member"
  defp resource_name(Mv.Membership.Property), do: "Property"
  defp resource_name(Mv.Membership.PropertyType), do: "PropertyType"
  
  defp get_permission_from_cache(user_id, resource_name, action) do
    # Try to get from cache
    # Returns {:ok, true}, {:ok, false}, or :miss
    case PermissionCache.get_permission_set(user_id) do
      {:ok, permission_set} ->
        # Check if this permission set has the permission
        has_permission = 
          permission_set.resources
          |> Enum.any?(fn p ->
            p.resource_name == resource_name and
            p.action == to_string(action) and
            p.granted == true
          end)
        
        {:ok, has_permission}
      
      :miss ->
        :miss
    end
  end
  
  defp check_permission_from_db(user, resource_name, action) do
    # Load user's role and permission set
    user = Ash.load!(user, role: [permission_set: :resources])
    
    has_permission = 
      user.role.permission_set.resources
      |> Enum.any?(fn p ->
        p.resource_name == resource_name and
        p.action == to_string(action) and
        p.granted == true
      end)
    
    # Cache the entire permission set
    PermissionCache.put_permission_set(user.id, user.role.permission_set)
    
    has_permission
  end
  
  defp check_page_permission_from_db(user, page_path) do
    user = Ash.load!(user, role: [permission_set: :pages])
    
    has_access = 
      user.role.permission_set.pages
      |> Enum.any?(fn p -> p.page_path == page_path end)
    
    # Cache this specific page permission
    PermissionCache.put_page_permission(user.id, page_path, has_access)
    
    has_access
  end
  
  defp check_scope_for_record(user, action, resource, record) do
    # Load the permission to check scope
    user = Ash.load!(user, role: [permission_set: :resources])
    resource_name = resource_name(resource)
    
    permission = 
      user.role.permission_set.resources
      |> Enum.find(fn p ->
        p.resource_name == resource_name and
        p.action == to_string(action) and
        p.granted == true
      end)
    
    case permission do
      nil -> 
        false
      
      %{scope: "all"} -> 
        true
      
      %{scope: "own"} when resource == Mv.Accounts.User ->
        # Check if record.id == user.id
        record.id == user.id
      
      %{scope: "linked"} when resource == Mv.Membership.Member ->
        # Check if record.user_id == user.id
        record_with_user = Ash.load!(record, :user)
        case record_with_user.user do
          nil -> false
          %{id: user_id} -> user_id == user.id
        end
      
      %{scope: "linked"} when resource == Mv.Membership.Property ->
        # Check if record.member.user_id == user.id
        record_with_member = Ash.load!(record, member: :user)
        case record_with_member.member do
          nil -> false
          %{user: nil} -> false
          %{user: %{id: user_id}} -> user_id == user.id
        end
      
      _ -> 
        false
    end
  end
  
  defp check_permission_and_scope_from_db(user, action, resource, record) do
    case check_permission_from_db(user, resource_name(resource), action) do
      false -> false
      true -> check_scope_for_record(user, action, resource, record)
    end
  end
end

Usage in LiveView Templates

Navigation Component:

<!-- lib/mv_web/components/layouts/navbar.html.heex -->
<nav class="navbar">
  <!-- Always visible -->
  <.link navigate="/">Home</.link>
  
  <!-- Only show if user can access members page -->
  <%= if can_access_page?(@current_user, "/members") do %>
    <.link navigate="/members">Members</.link>
  <% end %>
  
  <!-- Only show if user can access users page (admin only) -->
  <%= if can_access_page?(@current_user, "/users") do %>
    <.link navigate="/users">Users</.link>
  <% end %>
  
  <!-- Only show if user can access property types (admin only) -->
  <%= if can_access_page?(@current_user, "/property-types") do %>
    <.link navigate="/property-types">Custom Fields</.link>
  <% end %>
  
  <!-- Only show if user can access admin panel -->
  <%= if can_access_page?(@current_user, "/admin/roles") do %>
    <.link navigate="/admin/roles">Roles</.link>
  <% end %>
</nav>

Index Page with Action Buttons:

<!-- lib/mv_web/member_live/index.html.heex -->
<div class="page-header">
  <h1>Members</h1>
  
  <!-- Only show "New Member" 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 rows={@members}>
  <:col :let={member} label="Name">
    <%= member.first_name %> <%= member.last_name %>
  </:col>
  
  <:col :let={member} label="Email">
    <%= member.email %>
  </:col>
  
  <:col :let={member} label="Actions">
    <!-- Always show "View" if user can read -->
    <.link navigate={~p"/members/#{member}"} class="btn-secondary">
      Show
    </.link>
    
    <!-- Only show "Edit" if user can update THIS member -->
    <%= if can?(@current_user, :update, member) do %>
      <.link patch={~p"/members/#{member}/edit"} class="btn-secondary">
        Edit
      </.link>
    <% end %>
    
    <!-- Only show "Delete" if user can destroy THIS member -->
    <%= if can?(@current_user, :destroy, member) do %>
      <.button phx-click="delete" phx-value-id={member.id} class="btn-danger">
        Delete
      </.button>
    <% end %>
  </:col>
</.table>

Show Page:

<!-- lib/mv_web/member_live/show.html.heex -->
<div class="page-header">
  <h1>Member: <%= @member.first_name %> <%= @member.last_name %></h1>
  
  <div class="actions">
    <!-- Only show edit button if user can update THIS member -->
    <%= if can?(@current_user, :update, @member) do %>
      <.link patch={~p"/members/#{@member}/edit"} class="btn-primary">
        Edit
      </.link>
    <% end %>
    
    <!-- Only show delete button if user can destroy THIS member -->
    <%= if can?(@current_user, :destroy, @member) do %>
      <.button phx-click="delete" phx-value-id={@member.id} class="btn-danger">
        Delete
      </.button>
    <% end %>
  </div>
</div>

<div class="member-details">
  <dl>
    <dt>First Name</dt>
    <dd><%= @member.first_name %></dd>
    
    <dt>Last Name</dt>
    <dd><%= @member.last_name %></dd>
    
    <dt>Email</dt>
    <dd><%= @member.email %></dd>
    
    <!-- Phase 2: Field-level permissions -->
    <!-- Only show birth_date if user can read this field -->
    <%= if can_read_field?(@current_user, @member, :birth_date) do %>
      <dt>Birth Date</dt>
      <dd><%= @member.birth_date %></dd>
    <% end %>
  </dl>
</div>

Usage in LiveView Modules

Mount Hook:

defmodule MvWeb.MemberLive.Index do
  use MvWeb, :live_view
  
  import MvWeb.Authorization
  
  def mount(_params, _session, socket) do
    current_user = socket.assigns.current_user
    
    # Check if user can even access this page
    # (This is redundant with router plug, but provides better UX)
    unless can_access_page?(current_user, "/members") do
      {:ok, 
       socket
       |> put_flash(:error, "You don't have permission to access this page")
       |> redirect(to: ~p"/")}
    else
      members = list_members(current_user)
      
      {:ok, 
       socket
       |> assign(:members, members)
       |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))}
    end
  end
  
  defp list_members(current_user) do
    # Ash automatically filters based on policies
    Mv.Membership.Member
    |> Ash.read!(actor: current_user)
  end
  
  def handle_event("delete", %{"id" => id}, socket) do
    current_user = socket.assigns.current_user
    member = Ash.get!(Mv.Membership.Member, id, actor: current_user)
    
    # Double-check permission (though Ash will also enforce)
    if can?(current_user, :destroy, member) do
      case Ash.destroy(member, actor: current_user) do
        {:ok, _} ->
          {:noreply, 
           socket
           |> put_flash(:info, "Member deleted successfully")
           |> push_navigate(to: ~p"/members")}
        
        {:error, _} ->
          {:noreply, put_flash(socket, :error, "Failed to delete member")}
      end
    else
      {:noreply, put_flash(socket, :error, "Permission denied")}
    end
  end
end

Performance Considerations

Caching:

  • Permission checks use ETS cache (from PermissionCache)
  • First call loads from DB and caches
  • Subsequent calls use cache
  • Cache invalidated on role/permission changes

Batch Checking:

For tables with many rows, we can optimize by checking once per resource type:

def mount(_params, _session, socket) do
  current_user = socket.assigns.current_user
  
  members = list_members(current_user)
  
  # Check permissions once for the resource type
  can_update_any = can?(current_user, :update, Mv.Membership.Member)
  can_destroy_any = can?(current_user, :destroy, Mv.Membership.Member)
  
  # Then check scope for each member (if needed)
  members_with_permissions = 
    Enum.map(members, fn member ->
      %{
        member: member,
        can_update: can_update_any && can_update_this?(current_user, member),
        can_destroy: can_destroy_any && can_destroy_this?(current_user, member)
      }
    end)
  
  {:ok, 
   socket
   |> assign(:members_with_permissions, members_with_permissions)
   |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))}
end

Testing UI Authorization

Test Strategy:

# test/mv_web/member_live/index_test.exs
defmodule MvWeb.MemberLive.IndexTest do
  use MvWeb.ConnCase, async: true
  
  import Phoenix.LiveViewTest
  
  describe "UI authorization for Mitglied role" do
    test "does not show 'New Member' button", %{conn: conn} do
      user = create_user_with_role("Mitglied")
      member = create_member_linked_to_user(user)
      conn = log_in_user(conn, user)
      
      {:ok, view, html} = live(conn, ~p"/members")
      
      refute html =~ "New Member"
      refute has_element?(view, "a", "New Member")
    end
    
    test "shows only 'Show' button for own member", %{conn: conn} do
      user = create_user_with_role("Mitglied")
      member = create_member_linked_to_user(user)
      conn = log_in_user(conn, user)
      
      {:ok, view, html} = live(conn, ~p"/members")
      
      # Show button should exist
      assert has_element?(view, "a[href='/members/#{member.id}']", "Show")
      
      # Edit and Delete buttons should NOT exist
      refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit")
      refute has_element?(view, "button[phx-click='delete']", "Delete")
    end
    
    test "does not show 'Users' link in navigation", %{conn: conn} do
      user = create_user_with_role("Mitglied")
      conn = log_in_user(conn, user)
      
      {:ok, view, html} = live(conn, ~p"/")
      
      refute html =~ "Users"
      refute has_element?(view, "a[href='/users']", "Users")
    end
  end
  
  describe "UI authorization for Kassenwart role" do
    test "shows 'New Member' button", %{conn: conn} do
      user = create_user_with_role("Kassenwart")
      conn = log_in_user(conn, user)
      
      {:ok, view, html} = live(conn, ~p"/members")
      
      assert html =~ "New Member"
      assert has_element?(view, "a", "New Member")
    end
    
    test "shows Edit and Delete buttons for all members", %{conn: conn} do
      user = create_user_with_role("Kassenwart")
      member1 = create_member()
      member2 = create_member()
      conn = log_in_user(conn, user)
      
      {:ok, view, _html} = live(conn, ~p"/members")
      
      # Both members should have Edit and Delete buttons
      assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit")
      assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit")
      
      # Note: Using a more flexible selector for delete buttons
      assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"]))
      assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"]))
    end
  end
  
  describe "UI authorization for Admin role" do
    test "shows all navigation links", %{conn: conn} do
      admin = create_user_with_role("Admin")
      conn = log_in_user(conn, admin)
      
      {:ok, view, html} = live(conn, ~p"/")
      
      assert html =~ "Members"
      assert html =~ "Users"
      assert html =~ "Custom Fields"
      assert html =~ "Roles"
      
      assert has_element?(view, "a[href='/members']", "Members")
      assert has_element?(view, "a[href='/users']", "Users")
      assert has_element?(view, "a[href='/property-types']", "Custom Fields")
      assert has_element?(view, "a[href='/admin/roles']", "Roles")
    end
  end
end

Future Extensions

Phase 2: Field-Level Permissions

Timeline: Sprint 4-5 (after Phase 1 is stable)

Goal: Allow permission sets to restrict access to specific fields of resources.

Database Schema (Already Prepared)

The permission_set_resources.field_name column is already in place:

  • NULL = all fields (Phase 1 default)
  • "field_name" = specific field (Phase 2)

Implementation Approach

Option A: Blacklist Approach (Recommended)

Permission with field_name = NULL grants access to all fields. Additional rows with granted = false deny specific fields.

-- Normal User can read all Member fields
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, field_name, granted)
VALUES 
  (normal_user_id, 'Member', 'read', NULL, true);

-- But NOT birth_date
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, field_name, granted)
VALUES 
  (normal_user_id, 'Member', 'read', 'birth_date', false);

-- And NOT notes
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, field_name, granted)
VALUES 
  (normal_user_id, 'Member', 'read', 'notes', false);

Evaluation Logic:

  1. Check if there's a permission with field_name = NULL and granted = true
  2. If yes: Load all deny entries (field_name != NULL and granted = false)
  3. Deselect those fields from query

Advantages:

  • Default is "allow all fields"
  • Only need to specify exceptions
  • Easy to add new fields (automatically included)

Disadvantages:

  • Can't have different scopes per field (all fields have same scope)

Option B: Whitelist Approach

No permission with field_name = NULL. Only explicit granted = true entries allow access.

-- Read-Only can ONLY read these specific Member fields
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, field_name, granted)
VALUES 
  (read_only_id, 'Member', 'read', 'first_name', true),
  (read_only_id, 'Member', 'read', 'last_name', true),
  (read_only_id, 'Member', 'read', 'email', true),
  (read_only_id, 'Member', 'read', 'phone_number', true);
-- birth_date, notes, etc. are implicitly denied

Evaluation Logic:

  1. Check if there's a permission with field_name = NULL and granted = true
  2. If no: Load all allow entries (field_name != NULL and granted = true)
  3. Only select those fields in query

Advantages:

  • Explicit "allow" model (more secure default)
  • Could have different scopes per field (future feature)

Disadvantages:

  • Tedious to specify every allowed field
  • New fields are denied by default (requires permission update)

Custom Preparation for Field Filtering

defmodule Mv.Authorization.Preparations.FilterFieldsByPermission do
  use Ash.Resource.Preparation
  
  def prepare(query, _opts, context) do
    actor = context.actor
    action = query.action
    resource = query.resource
    
    # Get denied fields for this actor/resource/action
    denied_fields = get_denied_fields(actor, resource, action.name)
    
    # Deselect denied fields
    Ash.Query.deselect(query, denied_fields)
  end
  
  defp get_denied_fields(actor, resource, action) do
    permission_set = get_permission_set(actor)
    resource_name = resource |> Module.split() |> List.last()
    
    # Query denied fields (blacklist approach)
    Mv.Authorization.PermissionSetResource
    |> Ash.Query.filter(
      permission_set_id == ^permission_set.id and
      resource_name == ^resource_name and
      action == ^action and
      not is_nil(field_name) and
      granted == false
    )
    |> Ash.read!()
    |> Enum.map(& &1.field_name)
    |> Enum.map(&String.to_existing_atom/1)
  end
end

# Add to resources in Phase 2
defmodule Mv.Membership.Member do
  preparations do
    prepare Mv.Authorization.Preparations.FilterFieldsByPermission
  end
end

Custom Fields (Properties) Field-Level

For custom fields, field-level permissions work differently:

Approach: field_name stores the PropertyType name (not Property field)

-- Read-Only can ONLY see "membership_number" custom field
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, field_name, granted)
VALUES 
  (read_only_id, 'Property', 'read', 'membership_number', true);

-- All other PropertyTypes are denied

Implementation:

defmodule Mv.Authorization.Preparations.FilterPropertiesByType do
  use Ash.Resource.Preparation
  
  def prepare(query, _opts, context) do
    actor = context.actor
    
    # Get allowed PropertyType names
    allowed_types = get_allowed_property_types(actor)
    
    if allowed_types == :all do
      query
    else
      # Filter: only load Properties of allowed types
      Ash.Query.filter(query, property_type.name in ^allowed_types)
    end
  end
end

Phase 3: Payment History Permissions

Timeline: When Payment resource is implemented

Goal: Control access to payment-related data.

Implementation

-- Add Payment resource permissions
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, scope, granted)
VALUES 
  -- Own Data: Can see own payment history (optional)
  (own_data_id, 'Payment', 'read', 'linked', false),  -- Default: disabled
  
  -- Read-Only: Cannot see payment history
  (read_only_id, 'Payment', 'read', 'all', false),
  
  -- Normal User (Kassenwart): Can see and edit all payment history
  (normal_user_id, 'Payment', 'read', 'all', true),
  (normal_user_id, 'Payment', 'create', 'all', true),
  (normal_user_id, 'Payment', 'update', 'all', true),
  
  -- Admin: Full access
  (admin_id, 'Payment', 'read', 'all', true),
  (admin_id, 'Payment', 'create', 'all', true),
  (admin_id, 'Payment', 'update', 'all', true),
  (admin_id, 'Payment', 'destroy', 'all', true);

Configuration UI:

Admin UI will allow toggling "Members can view their own payment history" which updates the Own Data permission set:

# Toggle payment history visibility for members
def toggle_member_payment_visibility(enabled) do
  own_data_ps = get_permission_set_by_name("own_data")
  
  # Find or create Payment.read permission
  permission = 
    PermissionSetResource
    |> Ash.Query.filter(
      permission_set_id == ^own_data_ps.id and
      resource_name == "Payment" and
      action == "read" and
      scope == "linked"
    )
    |> Ash.read_one!()
  
  # Update granted flag
  Ash.Changeset.for_update(permission, :update, %{granted: enabled})
  |> Ash.update!()
end

Phase 4: Groups and Group Permissions

Timeline: TBD (future feature)

Goal: Group members and apply permissions per group.

Possible Approaches

Option 1: Group-scoped Permissions

Add group_id to permission_set_resources:

ALTER TABLE permission_set_resources
ADD COLUMN group_id UUID REFERENCES groups(id);

-- Normal User can only edit members in "Youth Group"
INSERT INTO permission_set_resources 
  (permission_set_id, resource_name, action, scope, group_id, granted)
VALUES 
  (normal_user_id, 'Member', 'update', 'all', youth_group_id, true);

Option 2: Group-based Roles

Roles can have group restrictions:

ALTER TABLE roles
ADD COLUMN group_id UUID REFERENCES groups(id);

-- "Youth Leader" role only has permissions for youth group
INSERT INTO roles (name, permission_set_id, group_id)
VALUES ('Youth Leader', normal_user_id, youth_group_id);

Decision: Deferred until Groups feature is designed.


Migration Strategy

Migration Plan

Sprint 1: Foundation

Week 1:

  • Create database migrations for permission tables
  • Create Ash resources (PermissionSet, Role, PermissionSetResource, PermissionSetPage)
  • Add role_id to users table
  • Create seed script for 4 permission sets

Week 2:

  • Implement custom policy checks
  • Implement permission cache (ETS)
  • Create seeds for 5 roles with permissions
  • Write tests for permission evaluation

Sprint 2: Integration

Week 3:

  • Implement router plug for page permissions
  • Update all existing resources with policies
  • Handle special cases (user credentials, member email)
  • Integration tests for common scenarios

Week 4:

  • Admin UI for role management
  • Admin UI for assigning roles to users
  • Documentation and user guide
  • Performance testing and optimization

Data Migration

Existing Users

All existing users will be assigned the "Mitglied" (Member) role by default:

-- Migration: Set default role for existing users
UPDATE users 
SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied')
WHERE role_id IS NULL;

Backward Compatibility

Phase 1:

  • No existing authorization system to maintain
  • Clean slate implementation

Phase 2 (Field-Level):

  • Existing permission_set_resources with field_name = NULL continue to work
  • No migration needed, just add new field-specific permissions

Security Considerations

Threat Model

1. Privilege Escalation

Threat: User tries to escalate privileges by manipulating requests.

Mitigation:

  • All authorization enforced server-side (Ash Policies)
  • Actor is verified via session
  • No client-side permission checks that can be bypassed
  • Cache invalidation ensures stale permissions aren't used

2. Permission Cache Poisoning

Threat: Attacker manipulates ETS cache to grant unauthorized access.

Mitigation:

  • ETS table is server-side only
  • Cache keys include user_id (can't access other users' cache)
  • Cache invalidated on any permission change
  • Fallback to database if cache returns unexpected data

3. SQL Injection via Scope Filters

Threat: Malicious actor value causes SQL injection in scope filters.

Mitigation:

  • All filters use Ash's expr() macro with parameterized queries
  • Actor ID is always a UUID (validated by database)
  • No string concatenation in filter construction

4. Permission Set Modification

Threat: Unauthorized user modifies permission sets or roles.

Mitigation:

  • Permission Sets have is_system = true and cannot be deleted
  • Role management requires Admin permission
  • Audit log (future) tracks all permission changes

5. Bypass via Direct Database Access

Threat: Code bypasses Ash and queries database directly.

Mitigation:

  • Code review enforces "always use Ash" policy
  • No raw SQL in application code
  • Database credentials secured via environment variables

6. Session Hijacking

Threat: Attacker steals session and impersonates user.

Mitigation:

  • Handled by AshAuthentication (out of scope for this document)
  • Sessions use signed tokens
  • HTTPS in production

Audit Logging (Future)

For compliance and debugging, implement audit log:

CREATE TABLE permission_audit_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  action VARCHAR(50),  -- "role_changed", "permission_modified"
  resource_type VARCHAR(255),
  resource_id UUID,
  old_value JSONB,
  new_value JSONB,
  timestamp TIMESTAMP NOT NULL DEFAULT now()
);

Appendix

Glossary

  • Permission Set: A predefined collection of permissions defining what actions can be performed
  • Role: A named job function (e.g., "Treasurer") that references one permission set
  • Resource: An Ash resource (e.g., Member, User, PropertyType)
  • Action: An Ash action (e.g., read, create, update, destroy)
  • Scope: The subset of records a permission applies to (own, linked, all)
  • Actor: The current user making a request
  • Page Permission: Access control for LiveView routes
  • Field-Level Permission: Restriction on specific fields of a resource (Phase 2)

Permission Set Summary

Permission Set Use Case Example Roles Resources Access
own_data Users accessing only their own data Mitglied User (own), Member (linked), Property (linked)
read_only Users who can view but not edit Vorstand, Buchhaltung Member (all, read), Property (all, read)
normal_user Users who can edit members and properties Kassenwart Member (all, read/write), Property (all, read/write)
admin Full administrative access Admin All resources (all, full CRUD)

Resource Permission Matrix

Resource Own Data Read-Only Normal User Admin
Member Linked: R/W All: R All: R/W All: Full
User Own: R/W None None All: Full
PropertyType All: R All: R All: R All: Full
Property Linked: R/W All: R All: R/W All: Full
Role None All: R None All: Full
Payment (future) Linked: R (config) None All: R/W All: Full

Page Permission Matrix

Page Path Own Data Read-Only Normal User Admin
/profile
/members
/members/:id
/members/new
/members/:id/edit
/users
/users/:id/edit
/property-types
/property-types/new
/admin

Document History

Version Date Author Changes
1.0 2025-11-10 Architecture Team Initial architecture design

End of Document