Add bypass vs HasPermission pattern documentation Update architecture and implementation plan docs
8.6 KiB
User Resource Authorization Policies - Implementation Summary
Date: 2026-01-22
Status: ✅ COMPLETED
Overview
Successfully implemented authorization policies for the User resource following the Bypass + HasPermission pattern, ensuring consistency with Member resource policies and proper use of the scope concept from PermissionSets.
What Was Implemented
1. Policy Structure in lib/accounts/user.ex
policies do
# 1. AshAuthentication Bypass
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
# 2. NoActor Bypass (test environment only)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.NoActor
end
# 3. Bypass for READ (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
# 4. HasPermission for all operations (uses scope from PermissionSets)
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
2. Test Suite in test/mv/accounts/user_policies_test.exs
Coverage:
- ✅ 31 tests total: 30 passing, 1 skipped
- ✅ All 4 permission sets tested:
own_data,read_only,normal_user,admin - ✅ READ operations (list and single record)
- ✅ UPDATE operations (own and other users)
- ✅ CREATE operations (admin only)
- ✅ DESTROY operations (admin only)
- ✅ AshAuthentication bypass (registration/login)
- ✅ NoActor bypass (test environment)
Key Design Decisions
Decision 1: Bypass for READ, HasPermission for UPDATE
Rationale:
- READ list queries have no record at
strict_checktime HasPermissionreturns{:ok, false}for queries without record- Ash doesn't call
auto_filterwhenstrict_checkreturnsfalse expr()in bypass is handled natively by Ash forauto_filter
Result:
- Bypass handles READ list queries ✅
- HasPermission handles UPDATE with
scope :own✅ - No redundancy - both are necessary ✅
Decision 2: No Explicit forbid_if always()
Rationale:
- Ash implicitly forbids if no policy authorizes (fail-closed by default)
- Explicit
forbid_if always()at the end breaks tests - It would forbid valid operations that should be authorized by previous policies
Result:
- Policies rely on Ash's implicit forbid ✅
- Tests pass with this approach ✅
Decision 3: Consistency with Member Resource
Rationale:
- Member resource uses same pattern: Bypass for READ, HasPermission for UPDATE
- Consistent patterns improve maintainability and predictability
- Developers can understand authorization logic across resources
Result:
- User and Member follow identical pattern ✅
- Authorization logic is consistent throughout the app ✅
The Scope Concept Is NOT Redundant
Initial Concern
"If we use a bypass with
expr(id == ^actor(:id))for READ, isn'tscope :ownin PermissionSets redundant?"
Resolution
NO! The scope concept is essential:
- Documentation -
scope :ownclearly expresses intent in PermissionSets - UPDATE operations -
scope :ownis USED by HasPermission when changeset contains record - Admin operations -
scope :allallows admins full access - Maintainability - All permissions centralized in one place
Test Proof:
test "can update own email", %{user: user} do
# This works via HasPermission with scope :own (NOT bypass)
{:ok, updated_user} =
user
|> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
|> Ash.update(actor: user)
assert updated_user.email # ✅ Proves scope :own is used
end
Documentation Updates
1. Created docs/policy-bypass-vs-haspermission.md
Comprehensive documentation explaining:
- Why bypass is needed for READ
- Why HasPermission works for UPDATE
- Technical deep dive into Ash policy evaluation
- Test coverage proving the pattern
- Lessons learned
2. Updated docs/roles-and-permissions-architecture.md
- Added "Bypass vs. HasPermission: When to Use Which?" section
- Updated User Resource Policies section with correct implementation
- Updated Member Resource Policies section for consistency
- Added pattern comparison table
3. Updated docs/roles-and-permissions-implementation-plan.md
- Marked Issue #8 as COMPLETED ✅
- Added implementation details
- Documented why bypass is needed
- Added test results
Test Results
All Relevant Tests Pass
mix test test/mv/accounts/user_policies_test.exs \
test/mv/authorization/checks/has_permission_test.exs \
test/mv/membership/member_policies_test.exs
# Results:
# 75 tests: 74 passing, 1 skipped
# ✅ User policies: 30/31 (1 skipped)
# ✅ HasPermission check: 21/21
# ✅ Member policies: 23/23
Specific Test Coverage
Own Data Access (All Roles):
- ✅ Can read own user record (via bypass)
- ✅ Can update own email (via HasPermission with scope :own)
- ✅ Cannot read other users (filtered by bypass)
- ✅ Cannot update other users (forbidden by HasPermission)
- ✅ List returns only own user (auto_filter via bypass)
Admin Access:
- ✅ Can read all users (HasPermission with scope :all)
- ✅ Can update other users (HasPermission with scope :all)
- ✅ Can create users (HasPermission with scope :all)
- ✅ Can destroy users (HasPermission with scope :all)
AshAuthentication:
- ✅ Registration works without actor
- ✅ OIDC registration works
- ✅ OIDC sign-in works
Test Environment:
- ✅ Operations without actor work in test environment
- ✅ NoActor bypass correctly detects compile-time environment
Files Changed
Implementation
- ✅
lib/accounts/user.ex- Added policies block (lines 271-315) - ✅
lib/mv/authorization/checks/has_permission.ex- Added User resource support inevaluate_filter_for_strict_check
Tests
- ✅
test/mv/accounts/user_policies_test.exs- Created comprehensive test suite (435 lines) - ✅
test/mv/authorization/checks/has_permission_test.exs- Updated to expectfalseinstead of:unknown
Documentation
- ✅
docs/policy-bypass-vs-haspermission.md- New comprehensive guide (created) - ✅
docs/roles-and-permissions-architecture.md- Updated User and Member sections - ✅
docs/roles-and-permissions-implementation-plan.md- Marked Issue #8 as completed - ✅
docs/user-resource-policies-implementation-summary.md- This file (created)
Lessons Learned
1. Test Before Assuming
The initial plan assumed HasPermission with scope :own would be sufficient. Testing revealed that Ash's policy evaluation doesn't reliably call auto_filter when strict_check returns false or :unknown.
2. Bypass Is Not a Workaround, It's a Pattern
The bypass with expr() is not a hack or workaround - it's the correct pattern for filter-based authorization in Ash when dealing with list queries.
3. Scope Concept Remains Essential
Even with bypass for READ, the scope concept in PermissionSets is essential for:
- UPDATE/CREATE/DESTROY operations
- Documentation and maintainability
- Centralized permission management
4. Consistency Across Resources
Following the same pattern (Bypass for READ, HasPermission for UPDATE) across User and Member resources makes the codebase more maintainable and predictable.
5. Documentation Is Key
Thorough documentation explaining WHY the pattern exists prevents future confusion and ensures the pattern is applied correctly in future resources.
Future Considerations
If Adding New Resources with Filter-Based Permissions
Follow the same pattern:
- Bypass with
expr()for READ (list queries) - HasPermission for UPDATE/CREATE/DESTROY (uses scope from PermissionSets)
- Define appropriate scopes in PermissionSets (
:own,:linked,:all)
If Ash Framework Changes
If a future version of Ash reliably calls auto_filter when strict_check returns :unknown:
- Consider removing bypass for READ
- Keep only HasPermission policy
- Update tests to verify new behavior
- Update documentation
For now (Ash 3.13.1), the current pattern is correct and necessary.
Conclusion
✅ User Resource Authorization Policies are fully implemented, tested, and documented.
The implementation:
- Follows best practices for Ash policies
- Is consistent with Member resource pattern
- Uses the scope concept from PermissionSets effectively
- Has comprehensive test coverage
- Is thoroughly documented for future developers
Status: PRODUCTION READY 🎉