Some checks reported errors
continuous-integration/drone/push Build was killed
CRITICAL FIX: Deny-filter was allowing all records instead of denying Fix: User validation in Member now uses actor from changeset.context
480 lines
14 KiB
Markdown
480 lines
14 KiB
Markdown
# Learning: Ash Framework, Policies, Phoenix & Elixir
|
|
|
|
## Session Overview
|
|
This document captures key learnings from implementing authorization policies for the `Member` resource using Ash Framework 3.0.
|
|
|
|
---
|
|
|
|
## 1. Ash Framework Policies
|
|
|
|
### 1.1 Enabling Policies
|
|
- **Critical:** Policies require the `Ash.Policy.Authorizer` extension to be explicitly enabled
|
|
- Must be added in the resource definition:
|
|
```elixir
|
|
use Ash.Resource,
|
|
domain: Mv.Membership,
|
|
data_layer: AshPostgres.DataLayer,
|
|
authorizers: [Ash.Policy.Authorizer] # Required for policies!
|
|
```
|
|
- Without this, the `policies` DSL block will not be available and will cause compilation errors
|
|
|
|
### 1.2 Action Types vs Action Names
|
|
- **Key Discovery:** Policies match on **action types**, not specific action names
|
|
- Action Types: `:create`, `:read`, `:update`, `:destroy`
|
|
- Action Names: `:create_member`, `:update_member`, `:read_member`, etc.
|
|
|
|
**Problem:**
|
|
```elixir
|
|
# In HasPermission check - WRONG:
|
|
action = authorizer.subject.action.name # Returns :create_member
|
|
```
|
|
|
|
**Solution:**
|
|
```elixir
|
|
# In HasPermission check - CORRECT:
|
|
action = authorizer.subject.action_type # Returns :create
|
|
```
|
|
|
|
**Why this matters:** The `PermissionSets` module defines permissions by action type (`:create`, `:read`, etc.), not by specific action names. Extracting the action name instead of the action type caused permission lookups to fail.
|
|
|
|
### 1.3 Policy Structure
|
|
```elixir
|
|
policies do
|
|
# Policy order matters! Specific before general
|
|
|
|
# Special cases first (optional)
|
|
policy action_type(:read) do
|
|
description "Special case for linked members"
|
|
authorize_if SomeCustomCheck
|
|
end
|
|
|
|
# General authorization
|
|
policy action_type([:read, :create, :update, :destroy]) do
|
|
description "Standard permission check"
|
|
authorize_if HasPermission
|
|
end
|
|
end
|
|
```
|
|
|
|
### 1.4 Policy Directives
|
|
- `authorize_if`: Grants access if check passes
|
|
- `forbid_if`: Denies access if check passes
|
|
- `bypass`: Allows access if check passes, skipping remaining policies
|
|
- `policy do ... end`: Groups multiple checks together
|
|
|
|
### 1.5 Policy Evaluation Results
|
|
Custom policy checks can return:
|
|
- `{:ok, true}`: Authorization granted
|
|
- `{:ok, false}`: Authorization denied
|
|
- `{:ok, :unknown}`: Let other policies evaluate
|
|
|
|
**Use Case:** The `NoActor` check returns `:unknown` when an actor is present, allowing other policies to take over:
|
|
```elixir
|
|
def strict_check(actor, _authorizer, _opts) do
|
|
if is_nil(actor) do
|
|
{:ok, true} # Allow if no actor (for seeds)
|
|
else
|
|
{:ok, :unknown} # Let other policies decide
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Custom Policy Checks
|
|
|
|
### 2.1 Structure
|
|
```elixir
|
|
defmodule Mv.Authorization.Checks.MyCheck do
|
|
use Ash.Policy.Check
|
|
|
|
@impl true
|
|
def describe(_opts), do: "description for debugging"
|
|
|
|
@impl true
|
|
def strict_check(actor, authorizer, _opts) do
|
|
# Runtime authorization logic
|
|
{:ok, true | false | :unknown}
|
|
end
|
|
|
|
@impl true
|
|
def auto_filter(actor, authorizer, _opts) do
|
|
# Query filter for list operations
|
|
nil | {:filter, expr(...)}
|
|
end
|
|
end
|
|
```
|
|
|
|
### 2.2 strict_check vs auto_filter
|
|
- **`strict_check`:** Called for single record operations (get, create, update, destroy)
|
|
- **`auto_filter`:** Called for list/query operations to filter results
|
|
|
|
**Critical:** Both must be implemented correctly for comprehensive authorization!
|
|
|
|
**Example from HasPermission:**
|
|
```elixir
|
|
def strict_check(actor, authorizer, _opts) do
|
|
# Check single record authorization
|
|
has_permission?(actor, authorizer)
|
|
end
|
|
|
|
def auto_filter(actor, authorizer, _opts) do
|
|
# Return filter for list queries
|
|
case get_scope(actor, authorizer) do
|
|
:all -> nil # No filter needed
|
|
:linked -> {:filter, expr(id == ^actor.member_id)}
|
|
_ -> {:error, :forbidden}
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Inverse Relationships & Authorization
|
|
|
|
### 3.1 The Problem
|
|
When implementing the "linked member" special case, initial attempts failed:
|
|
```elixir
|
|
# WRONG: Tries to traverse from Member to User
|
|
policy action_type(:read) do
|
|
authorize_if expr(user.id == ^actor(:id))
|
|
end
|
|
```
|
|
|
|
**Error:** `user` relationship is not loaded/available in this context
|
|
|
|
### 3.2 Understanding the Relationship
|
|
```
|
|
User (member_id) ──FK──> Member (id)
|
|
^ |
|
|
└──── has_one :user ─┘ (inverse)
|
|
```
|
|
|
|
- `User` table has `member_id` column (foreign key)
|
|
- `Member` has `has_one :user` (inverse relationship)
|
|
- **No foreign key on Member table pointing to User!**
|
|
|
|
### 3.3 The Solution
|
|
Check the relationship from the correct direction:
|
|
```elixir
|
|
# CORRECT: Check if actor's member_id matches this member's id
|
|
policy action_type(:read) do
|
|
authorize_if expr(^actor(:member_id) == id)
|
|
end
|
|
```
|
|
|
|
### 3.4 Applying to Scopes
|
|
In `HasPermission.apply_scope/3` for `:linked` scope:
|
|
|
|
```elixir
|
|
case resource_name do
|
|
"Member" ->
|
|
# Member has_one :user (inverse), FK is on User side
|
|
{:filter, expr(id == ^actor.member_id)}
|
|
|
|
"CustomFieldValue" ->
|
|
# CustomFieldValue belongs_to :member
|
|
# User.member_id → Member.id → CustomFieldValue.member_id
|
|
{:filter, expr(member.id == ^actor.member_id)}
|
|
end
|
|
```
|
|
|
|
**Key Insight:** Always check which table has the foreign key!
|
|
|
|
---
|
|
|
|
## 4. SAT Solver for Policy Strictness
|
|
|
|
### 4.1 The Warning
|
|
```
|
|
No SAT solver is available, so some policy strictness checks cannot be run
|
|
```
|
|
|
|
### 4.2 The Solution
|
|
Add a SAT solver dependency to `mix.exs`:
|
|
```elixir
|
|
{:picosat_elixir, "~> 0.1", only: [:dev, :test]}
|
|
```
|
|
|
|
### 4.3 Why It's Needed
|
|
Ash uses a SAT solver to verify that policies don't have logical contradictions or unreachable branches. This is a compile-time safety check.
|
|
|
|
---
|
|
|
|
## 5. Testing Authorization
|
|
|
|
### 5.1 Actor in Tests
|
|
**Critical:** Authorization requires an actor to be passed to all operations
|
|
|
|
```elixir
|
|
# Create with actor
|
|
{:ok, member} = Membership.create_member(%{...}, actor: user)
|
|
|
|
# Read with actor
|
|
{:ok, member} = Ash.get(Mv.Membership.Member, id, actor: user, domain: Mv.Membership)
|
|
|
|
# List with actor
|
|
members = Membership.list_members!(actor: user)
|
|
```
|
|
|
|
### 5.2 Reloading Actor After Relationships Change
|
|
**Problem:** After linking a member to a user, the `user` struct in memory doesn't have the updated `member_id`.
|
|
|
|
```elixir
|
|
# Link member to user
|
|
{:ok, member} = member
|
|
|> Ash.Changeset.for_update(:update_member, %{})
|
|
|> Ash.Changeset.manage_relationship(:user, user, type: :append_and_remove)
|
|
|> Ash.update(actor: admin_user)
|
|
|
|
# CRITICAL: Reload user to get updated member_id!
|
|
user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts)
|
|
# Now user.member_id is populated
|
|
```
|
|
|
|
### 5.3 Test Fixtures with Admin Actor
|
|
When creating test fixtures, use an admin actor to bypass authorization:
|
|
|
|
```elixir
|
|
defp create_member_linked_to(user) do
|
|
# Create admin for fixture setup
|
|
admin_user = create_admin_user()
|
|
|
|
# Create member as admin
|
|
{:ok, member} = Membership.create_member(%{...}, actor: admin_user)
|
|
|
|
# Link member as admin
|
|
{:ok, member} = member
|
|
|> Ash.Changeset.for_update(:update_member, %{})
|
|
|> Ash.Changeset.manage_relationship(:user, user, type: :append_and_remove)
|
|
|> Ash.update(actor: admin_user)
|
|
|
|
member
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Database Seeding with Authorization
|
|
|
|
### 6.1 The Problem
|
|
Once policies are active, seed scripts fail because they don't have an authenticated actor:
|
|
|
|
```elixir
|
|
# In priv/repo/seeds.exs
|
|
Membership.create_member!(%{...}) # Ash.Error.Forbidden!
|
|
```
|
|
|
|
### 6.2 Solution: NoActor Check
|
|
Create a custom check that allows actions without an actor:
|
|
|
|
```elixir
|
|
defmodule Mv.Authorization.Checks.NoActor do
|
|
use Ash.Policy.Check
|
|
|
|
def describe(_opts), do: "allows if no actor is present"
|
|
|
|
def strict_check(actor, _authorizer, _opts) do
|
|
if is_nil(actor) do
|
|
{:ok, true} # Allow seeding
|
|
else
|
|
{:ok, :unknown} # Let other policies decide
|
|
end
|
|
end
|
|
|
|
def auto_filter(_actor, _authorizer, _opts), do: nil
|
|
end
|
|
```
|
|
|
|
Then add to policies:
|
|
```elixir
|
|
policy action_type(:create) do
|
|
description "Allow seeding without actor"
|
|
authorize_if NoActor
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Debugging Policies
|
|
|
|
### 7.1 Enable Policy Breakdowns
|
|
Add to `config/test.exs`:
|
|
```elixir
|
|
config :ash, :policies, show_policy_breakdowns?: true
|
|
```
|
|
|
|
This shows detailed policy evaluation in test output.
|
|
|
|
### 7.2 NDJSON Logging
|
|
Structure for debug logs:
|
|
```elixir
|
|
defp log_debug(event, data) do
|
|
log_entry = %{
|
|
location: "has_permission.ex",
|
|
message: event,
|
|
data: data,
|
|
timestamp: System.system_time(:millisecond),
|
|
sessionId: "debug-session"
|
|
}
|
|
|
|
line = Jason.encode!(log_entry) <> "\n"
|
|
File.write!(".cursor/debug.log", line, [:append])
|
|
end
|
|
```
|
|
|
|
### 7.3 Strategic Log Placement
|
|
Place logs at:
|
|
1. **Function entry** with parameters
|
|
2. **Before critical operations** (e.g., before permission lookup)
|
|
3. **After critical operations** (e.g., after permission lookup with result)
|
|
4. **Branch execution** (which if/else path was taken)
|
|
5. **Function exit** with return value
|
|
|
|
### 7.4 Log Analysis Workflow
|
|
1. Delete old log file: `truncate -s 0 .cursor/debug.log`
|
|
2. Run failing test
|
|
3. Read log file line by line
|
|
4. Trace execution flow
|
|
5. Identify where actual behavior diverges from expected
|
|
|
|
---
|
|
|
|
## 8. Key Ash Concepts
|
|
|
|
### 8.1 Resources vs Domains
|
|
- **Resource:** Data model with attributes, relationships, actions (like `Member`)
|
|
- **Domain:** Logical grouping of resources (like `Membership`, `Accounts`)
|
|
- Domain must be specified in queries: `domain: Mv.Membership`
|
|
|
|
### 8.2 Actions
|
|
- **Create actions:** `:create`, `:create_member`
|
|
- **Read actions:** `:read`, `:get_member`, `:list_members`
|
|
- **Update actions:** `:update`, `:update_member`
|
|
- **Destroy actions:** `:destroy`, `:delete_member`
|
|
|
|
Each action has a **type** (one of the 4 above) and a **name** (specific)
|
|
|
|
### 8.3 Changesets
|
|
All modifications go through changesets:
|
|
```elixir
|
|
member
|
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "New"})
|
|
|> Ash.Changeset.manage_relationship(:user, user, type: :append_and_remove)
|
|
|> Ash.update(actor: current_user)
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Elixir Patterns
|
|
|
|
### 9.1 Pattern Matching in Policy Checks
|
|
```elixir
|
|
def strict_check(%{role: %{permissions: permissions}}, authorizer, _opts) do
|
|
# Actor has role with permissions
|
|
end
|
|
|
|
def strict_check(_actor, _authorizer, _opts) do
|
|
# Actor doesn't have role/permissions
|
|
{:ok, false}
|
|
end
|
|
```
|
|
|
|
### 9.2 With Expressions for Clean Authorization
|
|
```elixir
|
|
with %{role: %{permissions: permissions}} <- actor,
|
|
action <- get_action_from_authorizer(authorizer),
|
|
true <- check_permission(permissions, resource_name, action) do
|
|
{:ok, true}
|
|
else
|
|
_ -> {:ok, false}
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Common Pitfalls & Solutions
|
|
|
|
| Pitfall | Symptom | Solution |
|
|
|---------|---------|----------|
|
|
| Missing `Ash.Policy.Authorizer` | `undefined function policies/1` | Add `authorizers: [Ash.Policy.Authorizer]` |
|
|
| Using action name instead of type | `check_permission_not_found` | Use `authorizer.subject.action_type` not `.action.name` |
|
|
| Wrong relationship direction | Policy expression fails | Check which table has the FK |
|
|
| Actor not reloaded | `member_id` is `nil` after link | Reload actor from DB after relationship changes |
|
|
| Missing `auto_filter` | List queries return 0 results | Implement `auto_filter` in custom checks |
|
|
| Seeds fail after adding policies | `Ash.Error.Forbidden` in seeds | Add `NoActor` check or provide admin actor |
|
|
|
|
---
|
|
|
|
## 11. Session Metrics
|
|
|
|
- **Duration:** Multiple hours of debugging
|
|
- **Primary Issues Fixed:** 3 major (policies not loading, action type mismatch, inverse relationship)
|
|
- **Debug Iterations:** ~10+ test runs with progressive instrumentation
|
|
- **Files Modified:** 6 (member.ex, has_permission.ex, tests, config, mix.exs, seeds)
|
|
- **Tests Created:** 15+ covering all permission sets
|
|
- **Lines of Debug Logging Added:** ~50+ (across multiple files)
|
|
|
|
---
|
|
|
|
## 12. Best Practices Established
|
|
|
|
1. **Always enable policy authorizer explicitly** in resource definition
|
|
2. **Match on action types, not names** in policies and permission checks
|
|
3. **Understand relationship direction** before writing authorization filters
|
|
4. **Reload actors after relationship changes** in tests
|
|
5. **Implement both `strict_check` and `auto_filter`** for comprehensive authorization
|
|
6. **Use `NoActor` check for seeding** instead of disabling policies
|
|
7. **Add SAT solver dependency** for policy validation
|
|
8. **Enable policy breakdowns in test config** for debugging
|
|
9. **Use NDJSON logging for complex debugging** with strategic placement
|
|
10. **Test all permission sets systematically** (own_data, read_only, normal_user, admin)
|
|
|
|
---
|
|
|
|
## 13. Architecture Decisions
|
|
|
|
### 13.1 HasPermission as Central Check
|
|
Rather than spreading authorization logic across multiple policy checks, we centralized it in `HasPermission`:
|
|
- Single source of truth for permission logic
|
|
- Permission sets defined in one module (`PermissionSets`)
|
|
- Scopes (`:all`, `:linked`) applied consistently
|
|
|
|
### 13.2 Policy Order
|
|
Keep policies simple with clear order:
|
|
1. Special cases first (if any)
|
|
2. General HasPermission check
|
|
3. Implicit forbid (by Ash)
|
|
|
|
### 13.3 Minimal Special Cases
|
|
Initially tried multiple special case policies for linked members. **Simplified to just use HasPermission with scope handling:**
|
|
- Less complex policy block
|
|
- Easier to reason about
|
|
- Scope logic centralized in one place
|
|
|
|
---
|
|
|
|
## 14. Future Considerations
|
|
|
|
1. **Performance:** Monitor query performance with authorization filters on large datasets
|
|
2. **Caching:** Consider caching permission lookups if they become a bottleneck
|
|
3. **Audit Logging:** Add audit trail for authorization decisions (who accessed what)
|
|
4. **Dynamic Permissions:** Current system uses static permission sets; might need dynamic permissions per user in future
|
|
5. **Policy Testing:** Consider property-based testing for policies to catch edge cases
|
|
6. **Documentation:** Keep permission set documentation in sync with code
|
|
|
|
---
|
|
|
|
## 15. Resources & Documentation
|
|
|
|
- Ash Framework Policies: https://hexdocs.pm/ash/policies.html
|
|
- Ash Policy Authorizer: https://hexdocs.pm/ash/Ash.Policy.Authorizer.html
|
|
- Ash Policy Check: https://hexdocs.pm/ash/Ash.Policy.Check.html
|
|
- AshPostgres: https://hexdocs.pm/ash_postgres/
|
|
- Elixir Pattern Matching: https://elixir-lang.org/getting-started/pattern-matching.html
|
|
|
|
---
|
|
|
|
**Document Last Updated:** 2026-01-08
|
|
**Session ID:** debug-session-member-policies
|
|
**Status:** Debugging in progress - investigating list query filtering issues
|