mitgliederverwaltung/learning.md
Moritz fecf98dc0e
Some checks reported errors
continuous-integration/drone/push Build was killed
Security: Fix critical deny-filter bug and improve authorization
CRITICAL FIX: Deny-filter was allowing all records instead of denying
Fix: User validation in Member now uses actor from changeset.context
2026-01-08 23:12:54 +01:00

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.Authorizer extension 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 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:

# 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 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:

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)
  • 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:

# 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:

  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:

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

  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


Document Last Updated: 2026-01-08 Session ID: debug-session-member-policies Status: Debugging in progress - investigating list query filtering issues