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

2279 lines
66 KiB
Markdown

# 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](#overview)
2. [Requirements Analysis](#requirements-analysis)
3. [Evaluated Approaches](#evaluated-approaches)
4. [Selected Architecture](#selected-architecture)
5. [Database Schema](#database-schema)
6. [Permission System Design](#permission-system-design)
7. [Implementation Details](#implementation-details)
8. [Future Extensions](#future-extensions)
9. [Migration Strategy](#migration-strategy)
10. [Security Considerations](#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:**
```json
{
"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.
```sql
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.
```sql
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:**
```sql
-- 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.
```sql
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:**
```sql
-- 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.
```sql
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:**
```sql
-- 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.
```sql
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:**
```elixir
{: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:**
```elixir
# 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:**
```elixir
: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:
```elixir
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
```elixir
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:**
```elixir
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:**
```elixir
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):
```elixir
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:**
```elixir
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:**
```elixir
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:**
```elixir
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:**
```elixir
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`
```elixir
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:**
```heex
<!-- 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:**
```heex
<!-- 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:**
```heex
<!-- 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:**
```elixir
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:
```elixir
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:**
```elixir
# 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.
```sql
-- 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.
```sql
-- 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
```elixir
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)
```sql
-- 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:**
```elixir
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
```sql
-- 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:
```elixir
# 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:
```sql
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:
```sql
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:
```sql
-- 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:
```sql
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**