Clarify that User.update :own is handled by HasPermission. Fix file path references from lib/mv/accounts to lib/accounts.
10 KiB
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:
- Bypass with
expr()for READ operations - Handles list queries via auto_filter - 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(...)}:
strict_checkis called first- For list queries (no record yet),
strict_checkreturns{:ok, false} - Ash STOPS evaluation and does NOT call
auto_filter - Result: List queries fail with empty results ❌
Example:
# 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:
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 grantUser.update :own HasPermissionevaluatesscope :ownwhen 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'tscope :ownin PermissionSets redundant?"
The Answer: NO! ✅
scope :own is ONLY used for operations where a record is present:
# 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:
# 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
# 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 :ownfor read/updateadmin:scope :allfor all operations
Member Resource
# 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 :linkedfor read/updateread_only:scope :allfor read (no update permission)normal_user,admin:scope :allfor all operations
Technical Deep Dive
Why Does expr() in Bypass Work?
Ash treats expr() natively in two contexts:
-
strict_check (single record):
- Ash evaluates the expression against the record
- Returns true/false based on match
-
auto_filter (list queries):
- Ash compiles the expression to SQL WHERE clause
- Applies filter directly in database query
Example:
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:
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_filterwhenstrict_checkreturnsfalse 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:
# 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
- Don't assume that returning a filter from
strict_checkwill triggerauto_filter- test it! - Bypass with
expr()is necessary for list queries with filter-based permissions - Scope concept is NOT redundant - it's used for operations with records (UPDATE/CREATE/DESTROY)
- Consistency matters - following the same pattern across resources improves maintainability
- 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}:
- We could remove the bypass for READ
- Keep only the HasPermission policy for all operations
- 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
- 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