docs(auth): document User policies and bypass pattern
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
63d8c4668d
commit
5506b5b2dc
4 changed files with 757 additions and 65 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue