330 lines
10 KiB
Markdown
330 lines
10 KiB
Markdown
# 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 |
|
|
|
|
**Important: UPDATE Strategy**
|
|
|
|
UPDATE is **NOT** a hardcoded bypass. It is controlled by **PermissionSets**:
|
|
|
|
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own`
|
|
- `HasPermission` evaluates `scope :own` when a changeset with record is present
|
|
- If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials
|
|
- This is intentional - UPDATE is controlled by PermissionSets, not hardcoded
|
|
|
|
**Example:** The `read_only` permission set grants `User.update :own` even though it's "read-only" for member data. This allows password changes while keeping member data read-only.
|
|
|
|
---
|
|
|
|
## 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)
|
|
- ✅ Tests use system_actor for authorization
|
|
|
|
**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`
|