mitgliederverwaltung/docs/policy-bypass-vs-haspermission.md

6.1 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 — handles list queries via auto_filter.
  2. HasPermission for UPDATE/CREATE/DESTROY — uses scope from PermissionSets when a record is present.

This ensures the scope concept in PermissionSets is actually used and not redundant.

The Problem

The initial assumption was that HasPermission returning {:filter, expr(...)} would automatically trigger Ash's auto_filter for list queries. It does not:

  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. List queries fail with empty results.
# This FAILS for list queries:
policy action_type([:read, :update]) do
  authorize_if Mv.Authorization.Checks.HasPermission
end

# Ash.read(User, actor: user)
# Expected: [user] (filtered to own record)
# Actual:   [] (empty list)

The Solution

Bypass for READ, HasPermission for everything else:

policies do
  # AshAuthentication (registration/login)
  bypass AshAuthentication.Checks.AshAuthenticationInteraction do
    authorize_if always()
  end

  # 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 — scope from PermissionSets, used when a record is present
  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 it works:

Operation Record? Method Result
READ (list) No bypass + expr() Ash compiles expr to SQL WHERE → filtered list
READ (single) Yes bypass + expr() Ash evaluates expr → true/false
UPDATE / CREATE / DESTROY Yes (changeset) HasPermission + scope strict_check evaluates record → authorized

UPDATE is controlled by PermissionSets, not hardcoded

UPDATE is not a hardcoded bypass. All permission sets (:own_data, :read_only, :normal_user, :admin) explicitly grant User.update :own; HasPermission evaluates scope :own when a changeset with a record is present. Removing User.update :own from a set would remove credential-update ability for that set — intentional.

Decision: read_only grants User.update :own even though it is "read-only" for member data, so password changes work while member data stays read-only.

No explicit forbid_if always()

We do not add a trailing forbid_if always(). Ash fails closed implicitly — it forbids when no policy authorizes. An explicit terminal forbid breaks tests because it forbids valid operations that earlier policies should authorize.

Why scope :own Is NOT Redundant

scope :own is used for operations where a record is present (UPDATE/CREATE/DESTROY), even though the bypass handles READ:

# 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

Proven by test/mv/accounts/user_policies_test.exs ("can update own email"): the update succeeds via HasPermission with scope :own (not via bypass).

Consistency Across Resources

Both User and Member follow the same shape — bypass for READ, HasPermission for UPDATE/CREATE/DESTROY — differing only in the actor key and scope:

User Resource

bypass action_type(:read) do
  authorize_if expr(id == ^actor(:id))
end

PermissionSets: own_data / read_only / normal_user use scope :own for read/update; admin uses scope :all.

Member Resource

bypass action_type(:read) do
  authorize_if expr(id == ^actor(:member_id))
end

PermissionSets: own_data uses scope :linked for read/update; read_only uses scope :all for read (no update); normal_user and admin use scope :all.

Technical Deep Dive

Why expr() in bypass works

Ash treats expr() natively in both contexts:

  • strict_check (single record): evaluates the expression against the record → true/false.
  • auto_filter (list queries): compiles the expression to a SQL WHERE clause applied in the DB query.
# Ash.read(User, actor: user)
# Compiled SQL: SELECT * FROM users WHERE id = $1  → [user]

Why HasPermission doesn't trigger auto_filter

def strict_check(actor, authorizer, _opts) do
  case check_permission(...) do
    {:filter, filter_expr} ->
      if record do
        evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
      else
        # No record (list query) → return false. Ash STOPS, does NOT call auto_filter.
        {:ok, false}
      end
  end
end

Why return false, not :unknown? We tested returning :unknown; Ash's policy evaluation still did not reliably call auto_filter. The bypass with expr() is the only consistent solution. (has_permission_test.exs accordingly expects false, not :unknown.)

Future Considerations

If a future Ash version reliably calls auto_filter when strict_check returns :unknown or {:filter, expr}, the READ bypass could be removed and a single HasPermission policy kept for all operations (with tests updated). This workaround was first identified under Ash 3.13.x and is still required as of the Ash version pinned in mix.lock; the bypass pattern remains necessary and correct.

References

  • Ash policies: https://hexdocs.pm/ash/policies.html
  • Implementation: see the policies do block in Mv.Accounts.User (lib/accounts/user.ex)
  • Tests: test/mv/accounts/user_policies_test.exs, test/mv/authorization/checks/has_permission_test.exs
  • Architecture: docs/roles-and-permissions-architecture.md
  • Permission sets: lib/mv/authorization/permission_sets.ex