CRITICAL FIX: Deny-filter was allowing all records instead of denying Fix: User validation in Member now uses actor from changeset.context
14 KiB
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.Authorizerextension to be explicitly enabled - Must be added in the resource definition:
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer] # Required for policies!
- Without this, the
policiesDSL 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:
# In HasPermission check - WRONG:
action = authorizer.subject.action.name # Returns :create_member
Solution:
# 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
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 passesforbid_if: Denies access if check passesbypass: Allows access if check passes, skipping remaining policiespolicy 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:
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
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:
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:
# 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)
Usertable hasmember_idcolumn (foreign key)Memberhashas_one :user(inverse relationship)- No foreign key on Member table pointing to User!
3.3 The Solution
Check the relationship from the correct direction:
# 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:
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:
{: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
# 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.
# 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:
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:
# 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:
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:
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:
config :ash, :policies, show_policy_breakdowns?: true
This shows detailed policy evaluation in test output.
7.2 NDJSON Logging
Structure for debug logs:
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:
- Function entry with parameters
- Before critical operations (e.g., before permission lookup)
- After critical operations (e.g., after permission lookup with result)
- Branch execution (which if/else path was taken)
- Function exit with return value
7.4 Log Analysis Workflow
- Delete old log file:
truncate -s 0 .cursor/debug.log - Run failing test
- Read log file line by line
- Trace execution flow
- 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:
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
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
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
- Always enable policy authorizer explicitly in resource definition
- Match on action types, not names in policies and permission checks
- Understand relationship direction before writing authorization filters
- Reload actors after relationship changes in tests
- Implement both
strict_checkandauto_filterfor comprehensive authorization - Use
NoActorcheck for seeding instead of disabling policies - Add SAT solver dependency for policy validation
- Enable policy breakdowns in test config for debugging
- Use NDJSON logging for complex debugging with strategic placement
- 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:
- Special cases first (if any)
- General HasPermission check
- 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
- Performance: Monitor query performance with authorization filters on large datasets
- Caching: Consider caching permission lookups if they become a bottleneck
- Audit Logging: Add audit trail for authorization decisions (who accessed what)
- Dynamic Permissions: Current system uses static permission sets; might need dynamic permissions per user in future
- Policy Testing: Consider property-based testing for policies to catch edge cases
- 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