157 lines
6.1 KiB
Markdown
157 lines
6.1 KiB
Markdown
# 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.
|
|
|
|
```elixir
|
|
# 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:
|
|
|
|
```elixir
|
|
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:
|
|
|
|
```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
|
|
```
|
|
|
|
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
|
|
|
|
```elixir
|
|
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
|
|
|
|
```elixir
|
|
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.
|
|
|
|
```elixir
|
|
# Ash.read(User, actor: user)
|
|
# Compiled SQL: SELECT * FROM users WHERE id = $1 → [user]
|
|
```
|
|
|
|
### Why HasPermission doesn't trigger auto_filter
|
|
|
|
```elixir
|
|
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`
|