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.
2279 lines
66 KiB
Markdown
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**
|
|
|