docs(auth): document User policies and bypass pattern
All checks were successful
continuous-integration/drone/push Build is passing

Add bypass vs HasPermission pattern documentation
Update architecture and implementation plan docs
This commit is contained in:
Moritz 2026-01-22 19:19:27 +01:00
parent 63d8c4668d
commit 5506b5b2dc
4 changed files with 757 additions and 65 deletions

View file

@ -871,79 +871,166 @@ end
**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins.
---
## Bypass vs. HasPermission: When to Use Which?
**Key Finding:** For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier approach**:
1. **Bypass with `expr()` for READ** - Handles list queries (auto_filter)
2. **HasPermission for UPDATE/CREATE/DESTROY** - Handles operations with records
### Why This Pattern?
**The Problem with HasPermission for List Queries:**
When `HasPermission` returns `{:filter, expr(...)}` for `scope :own` or `scope :linked`:
- `strict_check` returns `{:ok, false}` for queries without a record
- Ash does **NOT** reliably call `auto_filter` when `strict_check` returns `false`
- Result: List queries fail ❌
**The Solution:**
Use `bypass` with `expr()` directly for READ operations:
- Ash handles `expr()` natively for both `strict_check` and `auto_filter`
- List queries work correctly ✅
- Single-record reads work correctly ✅
### Pattern Summary
| Operation | Has Record? | Use | Why |
|-----------|-------------|-----|-----|
| **READ (list)** | ❌ No | `bypass` with `expr()` | Triggers auto_filter |
| **READ (single)** | ✅ Yes | `bypass` with `expr()` | expr() evaluates to true/false |
| **UPDATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record |
| **CREATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record |
| **DESTROY** | ✅ Yes | `HasPermission` | strict_check can evaluate record |
### Is scope :own/:linked Still Useful?
**YES! ✅** The scope concept is essential:
1. **Documentation** - Clearly expresses intent in PermissionSets
2. **UPDATE/CREATE/DESTROY** - Works perfectly via HasPermission when record is present
3. **Consistency** - All permissions are centralized in PermissionSets
4. **Maintainability** - Easy to see what each role can do
The bypass is a **technical workaround** for Ash's auto_filter limitation, not a replacement for the scope concept.
### Consistency Across Resources
Both `User` and `Member` follow this pattern:
- **User**: Bypass for READ (`id == ^actor(:id)`), HasPermission for UPDATE (`scope :own`)
- **Member**: Bypass for READ (`id == ^actor(:member_id)`), HasPermission for UPDATE (`scope :linked`)
This ensures consistent behavior and predictable authorization logic throughout the application.
---
### User Resource Policies
**Location:** `lib/mv/accounts/user.ex`
**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role.
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own).
**Key Insight:** Bypass with `expr()` is needed ONLY for READ list queries because HasPermission's strict_check cannot properly trigger auto_filter. UPDATE operations work correctly via HasPermission because a changeset with record is available.
```elixir
defmodule Mv.Accounts.User do
use Ash.Resource, ...
policies do
# SPECIAL CASE: Users can always access their own account
# This takes precedence over permission checks
policy action_type([:read, :update]) do
description "Users can always read and update their own account"
# 1. AshAuthentication Bypass (registration/login without actor)
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
# 2. NoActor Bypass (test environment only, for test fixtures)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.NoActor
end
# 3. SPECIAL CASE: Users can always READ their own account
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
# UPDATE is handled by HasPermission below (scope :own works with changesets)
bypass action_type(:read) do
description "Users can always read their own account"
authorize_if expr(id == ^actor(:id))
end
# GENERAL: Other operations require permission
# (e.g., admin reading/updating other users, admin destroying users)
# 4. GENERAL: Check permissions from user's role
# - :own_data → can UPDATE own user (scope :own via HasPermission)
# - :read_only → can UPDATE own user (scope :own via HasPermission)
# - :normal_user → can UPDATE own user (scope :own via HasPermission)
# - :admin → can read/create/update/destroy all users (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Forbid if no policy matched
policy action_type([:read, :create, :update, :destroy]) do
forbid_if always()
end
# 5. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# ...
end
```
**Why Bypass for READ but not UPDATE?**
- **READ list queries** (`Ash.read(User, actor: user)`): No record at strict_check time → HasPermission returns `{:ok, false}` → auto_filter not called → bypass with `expr()` needed ✅
- **UPDATE operations** (`Ash.update(changeset, actor: user)`): Changeset contains record → HasPermission can evaluate `scope :own` correctly → works via HasPermission ✅
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|--------|----------|----------|------------|-------------|-------|
| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
| Read others | ❌ | ❌ | ❌ | ❌ | ✅ |
| Update others | ❌ | ❌ | ❌ | ❌ | ✅ |
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |
| Read own | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (scope :all) |
| Update own | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :all) |
| Read others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
| Update others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
| Create | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
**Note:** This pattern is consistent with Member resource policies (bypass for READ, HasPermission for UPDATE).
### Member Resource Policies
**Location:** `lib/mv/membership/member.ex`
**Special Case:** Users can always READ their linked member (where `id == user.member_id`).
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked).
**Key Insight:** Same pattern as User - bypass with `expr()` is needed ONLY for READ list queries. UPDATE operations work correctly via HasPermission because a changeset with record is available.
```elixir
defmodule Mv.Membership.Member do
use Ash.Resource, ...
policies do
# SPECIAL CASE: Users can always access their linked member
policy action_type([:read, :update]) do
description "Users can access member linked to their account"
authorize_if expr(user_id == ^actor(:id))
# 1. NoActor Bypass (test environment only, for test fixtures)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.NoActor
end
# GENERAL: Check permissions from role
# 2. SPECIAL CASE: Users can always READ their linked member
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
# UPDATE is handled by HasPermission below (scope :linked works with changesets)
bypass action_type(:read) do
description "Users can always read member linked to their account"
authorize_if expr(id == ^actor(:member_id))
end
# 3. GENERAL: Check permissions from role
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
# - :read_only → can READ all members (scope :all), no update permission
# - :normal_user → can CRUD all members (scope :all)
# - :admin → can CRUD all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Forbid
policy action_type([:read, :create, :update, :destroy]) do
forbid_if always()
end
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
end
# Custom validation for email editing (see Special Cases section)
@ -957,6 +1044,11 @@ defmodule Mv.Membership.Member do
end
```
**Why Bypass for READ but not UPDATE?**
- **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅
**Permission Matrix:**
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |