docs: add authorization bootstrap patterns section
All checks were successful
continuous-integration/drone/push Build is passing

Document the three authorization bypass mechanisms and when to use each:
- NoActor (test-only bypass)
- system_actor (systemic operations)
- authorize?: false (bootstrap scenarios)
This commit is contained in:
Moritz 2026-01-23 02:53:20 +01:00
parent 41e342a1d6
commit c98ad4085a
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 275 additions and 1 deletions

View file

@ -688,6 +688,44 @@ end
- **User Mode**: User-initiated actions use the actual user actor, policies are enforced - **User Mode**: User-initiated actions use the actual user actor, policies are enforced
- **System Mode**: Systemic operations use system actor, bypass user permissions - **System Mode**: Systemic operations use system actor, bypass user permissions
**Authorization Bootstrap Patterns:**
Three mechanisms exist for bypassing standard authorization:
1. **NoActor** (test only) - Allows operations without actor in test environment
```elixir
# Automatically enabled in tests via config/test.exs
# Policies use: bypass action_type(...) do authorize_if NoActor end
member = create_member(%{name: "Test"}) # Works in tests
```
2. **system_actor** (systemic operations) - Admin user for operations that must always succeed
```elixir
# Good: Systemic operation
system_actor = SystemActor.get_system_actor()
Ash.read(Member, actor: system_actor)
# Bad: User-initiated action
# Never use system_actor for user-initiated actions!
```
3. **authorize?: false** (bootstrap only) - Skips policies for circular dependencies
```elixir
# Good: Bootstrap (seeds, SystemActor loading)
Accounts.create_user!(%{email: admin_email}, authorize?: false)
# Bad: User-initiated action
Ash.destroy(member, authorize?: false) # Never do this!
```
**Decision Guide:**
- Use **NoActor** for test fixtures (automatic via config)
- Use **system_actor** for email sync, cycle generation, validations
- Use **authorize?: false** only for bootstrap (seeds, circular dependencies)
- Always document why `authorize?: false` is necessary
**See also:** `docs/roles-and-permissions-architecture.md` (Authorization Bootstrap Patterns section)
### 3.4 Ash Framework ### 3.4 Ash Framework
**Resource Definition Best Practices:** **Resource Definition Best Practices:**

View file

@ -2623,8 +2623,241 @@ iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
--- ---
## Authorization Bootstrap Patterns
This section clarifies three different mechanisms for bypassing standard authorization, their purposes, and when to use each.
### Overview
The codebase uses three authorization bypass mechanisms:
1. **NoActor** - Test-only bypass (compile-time secured)
2. **system_actor** - Admin user for systemic operations
3. **authorize?: false** - Bootstrap bypass for circular dependencies
**All three are necessary and serve different purposes.**
### 1. NoActor Check
**Purpose:** Allows CRUD operations without actor in test environment only.
**Implementation:**
```elixir
# lib/mv/authorization/checks/no_actor.ex
@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
def match?(nil, _context, _opts) do
@allow_no_actor_bypass # true in test.exs, false elsewhere
end
```
**Security:**
- Compile-time flag (not runtime `Mix.env()` check)
- Default: false (fail-closed)
- Only enabled in `config/test.exs`
**Use Case:** Test fixtures without verbose actor setup:
```elixir
# With NoActor (test environment only)
member = create_member(%{name: "Test"})
# Production behavior (NoActor returns false)
member = create_member(%{name: "Test"}, actor: user)
```
**Trade-off:** May mask tests that should fail without actor. Mitigated by explicit policy tests (e.g., `test/mv/accounts/user_policies_test.exs`).
### 2. System Actor
**Purpose:** Admin user for systemic operations that must always succeed regardless of user permissions.
**Implementation:**
```elixir
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# => %User{email: "system@mila.local", role: %{permission_set_name: "admin"}}
```
**Security:**
- No password (hashed_password = nil) → cannot login
- No OIDC ID (oidc_id = nil) → cannot authenticate
- Cached in Agent for performance
- Created automatically in test environment if missing
**Use Cases:**
- **Email synchronization** (User ↔ Member email sync)
- **Email uniqueness validation** (cross-resource checks)
- **Cycle generation** (mandatory side effect)
- **OIDC account linking** (user not yet logged in)
- **Cross-resource validations** (must work regardless of actor)
**Example:**
```elixir
def get_linked_member(%{member_id: id}) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Email sync must work regardless of user permissions
Ash.get(Mv.Membership.Member, id, opts)
end
```
**Why not `authorize?: false`?**
- System actor is explicit (clear intent: "systemic operation")
- Policies are evaluated (with admin rights)
- Audit trail (actor.email = "system@mila.local")
- Consistent authorization flow
- Testable
### 3. authorize?: false
**Purpose:** Skip policies for bootstrap scenarios with circular dependencies.
**Use Cases:**
**1. Seeds** - No admin exists yet to use as actor:
```elixir
# priv/repo/seeds.exs
Accounts.create_user!(%{email: admin_email},
authorize?: false # Bootstrap: no admin exists yet
)
```
**2. SystemActor Bootstrap** - Chicken-and-egg problem:
```elixir
# lib/mv/helpers/system_actor.ex
defp find_user_by_email(email) do
# Need to find system actor, but loading requires system actor!
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> Ash.read_one(authorize?: false) # Bootstrap only
end
```
**3. Actor.ensure_loaded** - Circular dependency:
```elixir
# lib/mv/authorization/actor.ex
defp load_role(actor) do
# Actor needs role for authorization,
# but loading role requires authorization!
Ash.load(actor, :role, authorize?: false) # Bootstrap only
end
```
**4. assign_default_role** - User creation:
```elixir
# User doesn't have actor during creation
Mv.Authorization.Role
|> Ash.Query.filter(name == "Mitglied")
|> Ash.read_one(authorize?: false) # Bootstrap only
```
**Security:**
- Very powerful - skips ALL policies
- Use sparingly and document every usage
- Only for bootstrap scenarios
- All current usages are legitimate
### Comparison
| Aspect | NoActor | system_actor | authorize?: false |
|--------|---------|--------------|-------------------|
| **Environment** | Test only | All | All |
| **Actor** | nil | Admin user | nil |
| **Policies** | Bypassed | Evaluated | Skipped |
| **Audit Trail** | No | Yes (system@mila.local) | No |
| **Use Case** | Test fixtures | Systemic operations | Bootstrap |
| **Explicit?** | Policy bypass | Function call | Query option |
### Decision Guide
**Use NoActor when:**
- ✅ Writing test fixtures
- ✅ Compile-time guard ensures test-only
**Use system_actor when:**
- ✅ Systemic operation must always succeed
- ✅ Email synchronization
- ✅ Cycle generation
- ✅ Cross-resource validations
- ✅ OIDC flows (user not logged in)
**Use authorize?: false when:**
- ✅ Bootstrap scenario (seeds)
- ✅ Circular dependency (SystemActor bootstrap, Actor.ensure_loaded)
- ⚠️ Document with comment explaining why
**DON'T:**
- ❌ Use `authorize?: false` for user-initiated actions
- ❌ Use `authorize?: false` when `system_actor` would work
- ❌ Enable NoActor outside test environment
### The Circular Dependency Problem
**SystemActor Bootstrap:**
```
SystemActor.get_system_actor()
↓ calls find_user_by_email()
↓ needs to query User
↓ User policies require actor
↓ but we're loading the actor!
Solution: authorize?: false for bootstrap query
```
**Actor.ensure_loaded:**
```
Authorization check (HasPermission)
↓ needs actor.role.permission_set_name
↓ but role is %Ash.NotLoaded{}
↓ load role with Ash.load(actor, :role)
↓ but loading requires authorization
↓ which needs actor.role!
Solution: authorize?: false for role load
```
**Why this is safe:**
- Actor is loading their OWN data (role relationship)
- Actor already passed authentication boundary
- Role contains no sensitive data (just permission_set reference)
- Alternative (denormalize permission_set_name) adds complexity
### Examples
**Good - system_actor for systemic operation:**
```elixir
defp check_if_email_used(email) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Validation must work regardless of current actor
Ash.read(User, opts)
end
```
**Good - authorize?: false for bootstrap:**
```elixir
# Seeds - no admin exists yet
Accounts.create_user!(%{email: admin_email}, authorize?: false)
```
**Bad - authorize?: false for user action:**
```elixir
# WRONG: Bypasses all policies for user-initiated action
def delete_member(member) do
Ash.destroy(member, authorize?: false) # ❌ Don't do this!
end
# CORRECT: Use actor
def delete_member(member, actor) do
Ash.destroy(member, actor: actor) # ✅ Policies enforced
end
```
---
**Document Version:** 2.0 (Clean Rewrite) **Document Version:** 2.0 (Clean Rewrite)
**Last Updated:** 2026-01-13 **Last Updated:** 2026-01-23
**Implementation Status:** ✅ Complete (2026-01-08) **Implementation Status:** ✅ Complete (2026-01-08)
**Status:** Ready for Implementation **Status:** Ready for Implementation
@ -2639,6 +2872,9 @@ iex> MvWeb.Authorization.can_access_page?(user, "/members/new")
- Added comprehensive security section - Added comprehensive security section
- Enhanced edge case documentation - Enhanced edge case documentation
**Changes from V2.0:**
- Added "Authorization Bootstrap Patterns" section explaining NoActor, system_actor, and authorize?: false
--- ---
**End of Architecture Document** **End of Architecture Document**