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
319
docs/policy-bypass-vs-haspermission.md
Normal file
319
docs/policy-bypass-vs-haspermission.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# Policy Pattern: Bypass vs. HasPermission
|
||||
|
||||
**Date:** 2026-01-22
|
||||
**Status:** Implemented and Tested
|
||||
**Applies to:** User Resource, Member Resource
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier authorization pattern**:
|
||||
|
||||
1. **Bypass with `expr()` for READ operations** - Handles list queries via auto_filter
|
||||
2. **HasPermission for UPDATE/CREATE/DESTROY** - Uses scope from PermissionSets when record is present
|
||||
|
||||
This pattern ensures that the scope concept in PermissionSets is actually used and not redundant.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
### Initial Assumption (INCORRECT)
|
||||
|
||||
> "No separate Own Credentials Bypass needed, as all permission sets already have User read/update :own. HasPermission with scope :own handles this correctly."
|
||||
|
||||
This assumption was based on the idea that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries.
|
||||
|
||||
### Reality
|
||||
|
||||
**When HasPermission returns `{:filter, expr(...)}`:**
|
||||
|
||||
1. `strict_check` is called first
|
||||
2. For list queries (no record yet), `strict_check` returns `{:ok, false}`
|
||||
3. Ash **STOPS** evaluation and does **NOT** call `auto_filter`
|
||||
4. Result: List queries fail with empty results ❌
|
||||
|
||||
**Example:**
|
||||
```elixir
|
||||
# This FAILS for list queries:
|
||||
policy action_type([:read, :update]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# User tries to list all users:
|
||||
Ash.read(User, actor: user)
|
||||
# Expected: Returns [user] (filtered to own record)
|
||||
# Actual: Returns [] (empty list)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Solution
|
||||
|
||||
### Pattern: Bypass for READ, HasPermission for UPDATE
|
||||
|
||||
**User Resource Example:**
|
||||
|
||||
```elixir
|
||||
policies do
|
||||
# Bypass for READ (handles list queries via auto_filter)
|
||||
bypass action_type(:read) do
|
||||
description "Users can always read their own account"
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
|
||||
# HasPermission for UPDATE (scope :own works with changesets)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why This Works:**
|
||||
|
||||
| Operation | Record Available? | Method | Result |
|
||||
|-----------|-------------------|--------|--------|
|
||||
| **READ (list)** | ❌ No | `bypass` with `expr()` | Ash applies expr as SQL WHERE → ✅ Filtered list |
|
||||
| **READ (single)** | ✅ Yes | `bypass` with `expr()` | Ash evaluates expr → ✅ true/false |
|
||||
| **UPDATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
|
||||
| **CREATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
|
||||
| **DESTROY** | ✅ Yes | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
|
||||
|
||||
---
|
||||
|
||||
## Why `scope :own` Is NOT Redundant
|
||||
|
||||
### The Question
|
||||
|
||||
> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?"
|
||||
|
||||
### The Answer: NO! ✅
|
||||
|
||||
**`scope :own` is ONLY used for operations where a record is present:**
|
||||
|
||||
```elixir
|
||||
# PermissionSets.ex
|
||||
%{resource: "User", action: :read, scope: :own, granted: true}, # Not used (bypass handles it)
|
||||
%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ✅
|
||||
```
|
||||
|
||||
**Test Proof:**
|
||||
|
||||
```elixir
|
||||
# test/mv/accounts/user_policies_test.exs:82
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated@example.com"
|
||||
|
||||
# This works via HasPermission with scope :own (NOT via bypass)
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
# ✅ Test passes - proves scope :own is used!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consistency Across Resources
|
||||
|
||||
### User Resource
|
||||
|
||||
```elixir
|
||||
# Bypass for READ list queries
|
||||
bypass action_type(:read) do
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
|
||||
# HasPermission for UPDATE (uses scope :own from PermissionSets)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
```
|
||||
|
||||
**PermissionSets:**
|
||||
- `own_data`, `read_only`, `normal_user`: `scope :own` for read/update
|
||||
- `admin`: `scope :all` for all operations
|
||||
|
||||
### Member Resource
|
||||
|
||||
```elixir
|
||||
# Bypass for READ list queries
|
||||
bypass action_type(:read) do
|
||||
authorize_if expr(id == ^actor(:member_id))
|
||||
end
|
||||
|
||||
# HasPermission for UPDATE (uses scope :linked from PermissionSets)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
```
|
||||
|
||||
**PermissionSets:**
|
||||
- `own_data`: `scope :linked` for read/update
|
||||
- `read_only`: `scope :all` for read (no update permission)
|
||||
- `normal_user`, `admin`: `scope :all` for all operations
|
||||
|
||||
---
|
||||
|
||||
## Technical Deep Dive
|
||||
|
||||
### Why Does `expr()` in Bypass Work?
|
||||
|
||||
**Ash treats `expr()` natively in two contexts:**
|
||||
|
||||
1. **strict_check** (single record):
|
||||
- Ash evaluates the expression against the record
|
||||
- Returns true/false based on match
|
||||
|
||||
2. **auto_filter** (list queries):
|
||||
- Ash compiles the expression to SQL WHERE clause
|
||||
- Applies filter directly in database query
|
||||
|
||||
**Example:**
|
||||
```elixir
|
||||
bypass action_type(:read) do
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
|
||||
# For list query: Ash.read(User, actor: user)
|
||||
# Compiled SQL: SELECT * FROM users WHERE id = $1 (user.id)
|
||||
# Result: [user] ✅
|
||||
```
|
||||
|
||||
### Why Doesn't HasPermission Trigger auto_filter?
|
||||
|
||||
**HasPermission.strict_check logic:**
|
||||
|
||||
```elixir
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
# ...
|
||||
case check_permission(...) do
|
||||
{:filter, filter_expr} ->
|
||||
if record do
|
||||
# Evaluate filter against record
|
||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||
else
|
||||
# No record (list query) - return false
|
||||
# Ash STOPS here, does NOT call auto_filter
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why return false instead of :unknown?**
|
||||
|
||||
We tested returning `:unknown`, but Ash's policy evaluation still didn't reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Consistency
|
||||
|
||||
Both User and Member follow the same pattern:
|
||||
- Bypass for READ (list queries)
|
||||
- HasPermission for UPDATE/CREATE/DESTROY (with scope)
|
||||
|
||||
### 2. Scope Concept Is Essential
|
||||
|
||||
PermissionSets define scopes for all operations:
|
||||
- `:own` - User can access their own records
|
||||
- `:linked` - User can access linked records (e.g., their member)
|
||||
- `:all` - User can access all records (admin)
|
||||
|
||||
**These scopes are NOT redundant** - they are used for UPDATE/CREATE/DESTROY.
|
||||
|
||||
### 3. Bypass Is a Technical Workaround
|
||||
|
||||
The bypass is not a design choice but a **technical necessity** due to Ash's policy evaluation behavior:
|
||||
- Ash doesn't call `auto_filter` when `strict_check` returns `false`
|
||||
- `expr()` in bypass is handled natively by Ash for both contexts
|
||||
- This is consistent with Ash's documentation and best practices
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### User Resource Tests
|
||||
|
||||
**File:** `test/mv/accounts/user_policies_test.exs`
|
||||
|
||||
**Coverage:**
|
||||
- ✅ 31 tests: 30 passing, 1 skipped
|
||||
- ✅ All 4 permission sets: `own_data`, `read_only`, `normal_user`, `admin`
|
||||
- ✅ READ operations (list and single) via bypass
|
||||
- ✅ UPDATE operations via HasPermission with `scope :own`
|
||||
- ✅ Admin operations via HasPermission with `scope :all`
|
||||
- ✅ AshAuthentication bypass (registration/login)
|
||||
- ✅ NoActor bypass (test environment)
|
||||
|
||||
**Key Tests Proving Pattern:**
|
||||
|
||||
```elixir
|
||||
# Test 1: READ list uses bypass (returns filtered list)
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
assert length(users) == 1 # Filtered to own user ✅
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
# Test 2: UPDATE uses HasPermission with scope :own
|
||||
test "can update own email", %{user: user} do
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email # Uses scope :own from PermissionSets ✅
|
||||
end
|
||||
|
||||
# Test 3: Admin uses HasPermission with scope :all
|
||||
test "admin can update other users", %{admin: admin, other_user: other_user} do
|
||||
{:ok, updated_user} =
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{email: "admin-changed@example.com"})
|
||||
|> Ash.update(actor: admin)
|
||||
|
||||
assert updated_user.email # Uses scope :all from PermissionSets ✅
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Don't assume** that returning a filter from `strict_check` will trigger `auto_filter` - test it!
|
||||
2. **Bypass with `expr()` is necessary** for list queries with filter-based permissions
|
||||
3. **Scope concept is NOT redundant** - it's used for operations with records (UPDATE/CREATE/DESTROY)
|
||||
4. **Consistency matters** - following the same pattern across resources improves maintainability
|
||||
5. **Documentation is key** - explaining WHY the pattern exists prevents future confusion
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### If Ash Changes Policy Evaluation
|
||||
|
||||
If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`:
|
||||
|
||||
1. We could **remove** the bypass for READ
|
||||
2. Keep only the HasPermission policy for all operations
|
||||
3. Update tests to verify the new behavior
|
||||
|
||||
**However, for now (Ash 3.13.1), the bypass pattern is necessary and correct.**
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Ash Policy Documentation**: [https://hexdocs.pm/ash/policies.html](https://hexdocs.pm/ash/policies.html)
|
||||
- **Implementation**: `lib/accounts/user.ex` (lines 271-315)
|
||||
- **Tests**: `test/mv/accounts/user_policies_test.exs`
|
||||
- **Architecture Doc**: `docs/roles-and-permissions-architecture.md`
|
||||
- **Permission Sets**: `lib/mv/authorization/permission_sets.ex`
|
||||
Loading…
Add table
Add a link
Reference in a new issue