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

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