# 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) - ✅ 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`