mitgliederverwaltung/docs/policy-bypass-vs-haspermission.md
Moritz 5506b5b2dc
All checks were successful
continuous-integration/drone/push Build is passing
docs(auth): document User policies and bypass pattern
Add bypass vs HasPermission pattern documentation
Update architecture and implementation plan docs
2026-01-22 19:19:27 +01:00

9.6 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:

  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:

# 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

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:

# 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 :own for read/update
  • admin: scope :all for 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 :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:

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_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:

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