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