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.
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
- Overview
- Requirements Analysis
- Evaluated Approaches
- Selected Architecture
- Database Schema
- Permission System Design
- Implementation Details
- Future Extensions
- Migration Strategy
- 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
- Separation of Concerns: Permission Sets (what you can do) vs. Roles (job titles/functions)
- Flexibility: Admins can configure permissions at runtime via database
- Performance: Leverage Ash Framework policies with ETS caching
- Extensibility: Architecture supports future field-level granularity
- 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)
- Own Data - Users can only access their own data
- Read-Only - Read access to all members, groups, and custom fields
- Normal User - Read and write access to members and custom fields
- 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
-
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
-
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
-
Payment History (Future):
- Configurable per permission set
- Members may or may not see their own payment history
-
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?
-
Meets Requirements:
- ✅ Configurable permissions (stored in database)
- ✅ Dynamic role creation
- ✅ Extensible to field-level
- ✅ Admin UI can modify at runtime
-
Performance:
- ✅ Normalized tables allow efficient queries
- ✅ Indexes on resource_name and action
- ✅ ETS cache for permission lookups
- ✅ Ash Policies translate to SQL filters
-
Maintainability:
- ✅ Clear separation of concerns
- ✅ Standard Ash patterns (not custom authorizer)
- ✅ Testable with standard Ash policy tests
- ✅ Easy to understand and debug
-
Extensibility:
- ✅
field_namecolumn reserved for Phase 2 - ✅
scopesystem handles "own" vs "all" vs "linked" - ✅ New resources just add permission rows
- ✅ No code changes needed for new roles
- ✅
-
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 dataread_only- Read access to all members and custom fieldsnormal_user- Read and write access to members and custom fieldsadmin- 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:
NULLor"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 = truefor "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.readwith 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.readwith 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.readwith 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:
- Database migrations for all permission tables
- Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage
- Custom policy checks
- Permission cache (ETS)
- Router plug for page permissions
- Seeds for 4 permission sets and 5 roles
- Admin UI for role management
- 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/editpage permission to see the page - User needs
Member.updatepermission 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:
- Navigation Links: Hide links to pages the user cannot access
- Action Buttons: Hide "Edit", "Delete", "New" buttons when user lacks permissions
- Form Fields: In Phase 2, hide fields the user cannot read/write
- 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:
- Check if there's a permission with
field_name = NULLandgranted = true - If yes: Load all deny entries (
field_name != NULLandgranted = false) - 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:
- Check if there's a permission with
field_name = NULLandgranted = true - If no: Load all allow entries (
field_name != NULLandgranted = true) - 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 = NULLcontinue 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 = trueand 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