# 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