User Resource Policies closes #363 #364
28 changed files with 1717 additions and 142 deletions
|
|
@ -1664,6 +1664,66 @@ case Ash.read(Mv.Membership.Member, actor: actor) do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5.1a NoActor Pattern - Test Environment Only
|
||||||
|
|
||||||
|
**IMPORTANT:** The `Mv.Authorization.Checks.NoActor` check is **ONLY for test environment**. It must NEVER be used in production.
|
||||||
|
|
||||||
|
**What NoActor Does:**
|
||||||
|
|
||||||
|
- Allows CRUD operations without an actor in **test environment only**
|
||||||
|
- Denies all operations without an actor in **production/dev** (fail-closed)
|
||||||
|
- Uses compile-time config check to prevent accidental production use (release-safe)
|
||||||
|
|
||||||
|
**Security Guards:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# config/test.exs
|
||||||
|
config :mv, :allow_no_actor_bypass, true
|
||||||
|
|
||||||
|
# lib/mv/authorization/checks/no_actor.ex
|
||||||
|
# Compile-time check from config (release-safe, no Mix.env)
|
||||||
|
@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
|
||||||
|
|
||||||
|
# Uses compile-time flag only (no runtime Mix.env needed)
|
||||||
|
def match?(nil, _context, _opts) do
|
||||||
|
@allow_no_actor_bypass # true in test, false in prod/dev
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Pattern Exists:**
|
||||||
|
|
||||||
|
- Test fixtures often need to create resources without an actor
|
||||||
|
- Production operations MUST always have an actor for security
|
||||||
|
- Config-based guard (not Mix.env) ensures release-safety
|
||||||
|
- Defaults to `false` (fail-closed) if config not set
|
||||||
|
|
||||||
|
**NEVER Use NoActor in Production:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# ❌ BAD - Don't do this in production code
|
||||||
|
Ash.create!(Member, attrs) # No actor - will fail in prod
|
||||||
|
|
||||||
|
# ✅ GOOD - Use admin actor for system operations
|
||||||
|
admin_user = get_admin_user()
|
||||||
|
Ash.create!(Member, attrs, actor: admin_user)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: System Actor Pattern**
|
||||||
|
|
||||||
|
For production system operations, use the System Actor Pattern (see Section 3.3) instead of NoActor:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# System operations in production
|
||||||
|
system_actor = get_system_actor()
|
||||||
|
Ash.create!(Member, attrs, actor: system_actor)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
|
||||||
|
- NoActor tests verify the compile-time config guard
|
||||||
|
- Production safety is guaranteed by config (only set in test.exs, defaults to false)
|
||||||
|
- See `test/mv/authorization/checks/no_actor_test.exs`
|
||||||
|
|
||||||
### 5.2 Password Security
|
### 5.2 Password Security
|
||||||
|
|
||||||
**Use bcrypt for Password Hashing:**
|
**Use bcrypt for Password Hashing:**
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,7 @@ config :mv, :require_token_presence_for_authentication, false
|
||||||
# Enable SQL Sandbox for async LiveView tests
|
# Enable SQL Sandbox for async LiveView tests
|
||||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||||
config :mv, :sql_sandbox, true
|
config :mv, :sql_sandbox, true
|
||||||
|
|
||||||
|
# Allow operations without actor in test environment (NoActor check)
|
||||||
|
# SECURITY: This must ONLY be true in test.exs, never in prod/dev
|
||||||
|
config :mv, :allow_no_actor_bypass, true
|
||||||
|
|
|
||||||
330
docs/policy-bypass-vs-haspermission.md
Normal file
330
docs/policy-bypass-vs-haspermission.md
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
# Policy Pattern: Bypass vs. HasPermission
|
||||||
|
|
||||||
|
**Date:** 2026-01-22
|
||||||
|
**Status:** Implemented and Tested
|
||||||
|
**Applies to:** User Resource, Member Resource
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier authorization pattern**:
|
||||||
|
|
||||||
|
1. **Bypass with `expr()` for READ operations** - Handles list queries via auto_filter
|
||||||
|
2. **HasPermission for UPDATE/CREATE/DESTROY** - Uses scope from PermissionSets when record is present
|
||||||
|
|
||||||
|
This pattern ensures that the scope concept in PermissionSets is actually used and not redundant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
### Initial Assumption (INCORRECT)
|
||||||
|
|
||||||
|
> "No separate Own Credentials Bypass needed, as all permission sets already have User read/update :own. HasPermission with scope :own handles this correctly."
|
||||||
|
|
||||||
|
This assumption was based on the idea that `HasPermission` returning `{:filter, expr(...)}` would automatically trigger Ash's `auto_filter` for list queries.
|
||||||
|
|
||||||
|
### Reality
|
||||||
|
|
||||||
|
**When HasPermission returns `{:filter, expr(...)}`:**
|
||||||
|
|
||||||
|
1. `strict_check` is called first
|
||||||
|
2. For list queries (no record yet), `strict_check` returns `{:ok, false}`
|
||||||
|
3. Ash **STOPS** evaluation and does **NOT** call `auto_filter`
|
||||||
|
4. Result: List queries fail with empty results ❌
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```elixir
|
||||||
|
# This FAILS for list queries:
|
||||||
|
policy action_type([:read, :update]) do
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
|
||||||
|
# User tries to list all users:
|
||||||
|
Ash.read(User, actor: user)
|
||||||
|
# Expected: Returns [user] (filtered to own record)
|
||||||
|
# Actual: Returns [] (empty list)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
### Pattern: Bypass for READ, HasPermission for UPDATE
|
||||||
|
|
||||||
|
**User Resource Example:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
policies do
|
||||||
|
# Bypass for READ (handles list queries via auto_filter)
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# HasPermission for UPDATE (scope :own works with changesets)
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from user's role and permission set"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Works:**
|
||||||
|
|
||||||
|
| Operation | Record Available? | Method | Result |
|
||||||
|
|-----------|-------------------|--------|--------|
|
||||||
|
| **READ (list)** | ❌ No | `bypass` with `expr()` | Ash applies expr as SQL WHERE → ✅ Filtered list |
|
||||||
|
| **READ (single)** | ✅ Yes | `bypass` with `expr()` | Ash evaluates expr → ✅ true/false |
|
||||||
|
| **UPDATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
|
||||||
|
| **CREATE** | ✅ Yes (changeset) | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
|
||||||
|
| **DESTROY** | ✅ Yes | `HasPermission` with `scope :own` | strict_check evaluates record → ✅ Authorized |
|
||||||
|
|
||||||
|
**Important: UPDATE Strategy**
|
||||||
|
|
||||||
|
UPDATE is **NOT** a hardcoded bypass. It is controlled by **PermissionSets**:
|
||||||
|
|
||||||
|
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant `User.update :own`
|
||||||
|
- `HasPermission` evaluates `scope :own` when a changeset with record is present
|
||||||
|
- If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials
|
||||||
|
- This is intentional - UPDATE is controlled by PermissionSets, not hardcoded
|
||||||
|
|
||||||
|
**Example:** The `read_only` permission set grants `User.update :own` even though it's "read-only" for member data. This allows password changes while keeping member data read-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why `scope :own` Is NOT Redundant
|
||||||
|
|
||||||
|
### The Question
|
||||||
|
|
||||||
|
> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?"
|
||||||
|
|
||||||
|
### The Answer: NO! ✅
|
||||||
|
|
||||||
|
**`scope :own` is ONLY used for operations where a record is present:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# PermissionSets.ex
|
||||||
|
%{resource: "User", action: :read, scope: :own, granted: true}, # Not used (bypass handles it)
|
||||||
|
%{resource: "User", action: :update, scope: :own, granted: true}, # USED by HasPermission ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Proof:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# test/mv/accounts/user_policies_test.exs:82
|
||||||
|
test "can update own email", %{user: user} do
|
||||||
|
new_email = "updated@example.com"
|
||||||
|
|
||||||
|
# This works via HasPermission with scope :own (NOT via bypass)
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
end
|
||||||
|
# ✅ Test passes - proves scope :own is used!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consistency Across Resources
|
||||||
|
|
||||||
|
### User Resource
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# Bypass for READ list queries
|
||||||
|
bypass action_type(:read) do
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# HasPermission for UPDATE (uses scope :own from PermissionSets)
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**PermissionSets:**
|
||||||
|
- `own_data`, `read_only`, `normal_user`: `scope :own` for read/update
|
||||||
|
- `admin`: `scope :all` for all operations
|
||||||
|
|
||||||
|
### Member Resource
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# Bypass for READ list queries
|
||||||
|
bypass action_type(:read) do
|
||||||
|
authorize_if expr(id == ^actor(:member_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# HasPermission for UPDATE (uses scope :linked from PermissionSets)
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**PermissionSets:**
|
||||||
|
- `own_data`: `scope :linked` for read/update
|
||||||
|
- `read_only`: `scope :all` for read (no update permission)
|
||||||
|
- `normal_user`, `admin`: `scope :all` for all operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Deep Dive
|
||||||
|
|
||||||
|
### Why Does `expr()` in Bypass Work?
|
||||||
|
|
||||||
|
**Ash treats `expr()` natively in two contexts:**
|
||||||
|
|
||||||
|
1. **strict_check** (single record):
|
||||||
|
- Ash evaluates the expression against the record
|
||||||
|
- Returns true/false based on match
|
||||||
|
|
||||||
|
2. **auto_filter** (list queries):
|
||||||
|
- Ash compiles the expression to SQL WHERE clause
|
||||||
|
- Applies filter directly in database query
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```elixir
|
||||||
|
bypass action_type(:read) do
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# For list query: Ash.read(User, actor: user)
|
||||||
|
# Compiled SQL: SELECT * FROM users WHERE id = $1 (user.id)
|
||||||
|
# Result: [user] ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Doesn't HasPermission Trigger auto_filter?
|
||||||
|
|
||||||
|
**HasPermission.strict_check logic:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
def strict_check(actor, authorizer, _opts) do
|
||||||
|
# ...
|
||||||
|
case check_permission(...) do
|
||||||
|
{:filter, filter_expr} ->
|
||||||
|
if record do
|
||||||
|
# Evaluate filter against record
|
||||||
|
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||||
|
else
|
||||||
|
# No record (list query) - return false
|
||||||
|
# Ash STOPS here, does NOT call auto_filter
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why return false instead of :unknown?**
|
||||||
|
|
||||||
|
We tested returning `:unknown`, but Ash's policy evaluation still didn't reliably call `auto_filter`. The `bypass` with `expr()` is the only consistent solution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
### 1. Consistency
|
||||||
|
|
||||||
|
Both User and Member follow the same pattern:
|
||||||
|
- Bypass for READ (list queries)
|
||||||
|
- HasPermission for UPDATE/CREATE/DESTROY (with scope)
|
||||||
|
|
||||||
|
### 2. Scope Concept Is Essential
|
||||||
|
|
||||||
|
PermissionSets define scopes for all operations:
|
||||||
|
- `:own` - User can access their own records
|
||||||
|
- `:linked` - User can access linked records (e.g., their member)
|
||||||
|
- `:all` - User can access all records (admin)
|
||||||
|
|
||||||
|
**These scopes are NOT redundant** - they are used for UPDATE/CREATE/DESTROY.
|
||||||
|
|
||||||
|
### 3. Bypass Is a Technical Workaround
|
||||||
|
|
||||||
|
The bypass is not a design choice but a **technical necessity** due to Ash's policy evaluation behavior:
|
||||||
|
- Ash doesn't call `auto_filter` when `strict_check` returns `false`
|
||||||
|
- `expr()` in bypass is handled natively by Ash for both contexts
|
||||||
|
- This is consistent with Ash's documentation and best practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### User Resource Tests
|
||||||
|
|
||||||
|
**File:** `test/mv/accounts/user_policies_test.exs`
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- ✅ 31 tests: 30 passing, 1 skipped
|
||||||
|
- ✅ All 4 permission sets: `own_data`, `read_only`, `normal_user`, `admin`
|
||||||
|
- ✅ READ operations (list and single) via bypass
|
||||||
|
- ✅ UPDATE operations via HasPermission with `scope :own`
|
||||||
|
- ✅ Admin operations via HasPermission with `scope :all`
|
||||||
|
- ✅ AshAuthentication bypass (registration/login)
|
||||||
|
- ✅ NoActor bypass (test environment)
|
||||||
|
|
||||||
|
**Key Tests Proving Pattern:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
# Test 1: READ list uses bypass (returns filtered list)
|
||||||
|
test "list users returns only own user", %{user: user} do
|
||||||
|
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||||
|
assert length(users) == 1 # Filtered to own user ✅
|
||||||
|
assert hd(users).id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test 2: UPDATE uses HasPermission with scope :own
|
||||||
|
test "can update own email", %{user: user} do
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email # Uses scope :own from PermissionSets ✅
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test 3: Admin uses HasPermission with scope :all
|
||||||
|
test "admin can update other users", %{admin: admin, other_user: other_user} do
|
||||||
|
{:ok, updated_user} =
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: "admin-changed@example.com"})
|
||||||
|
|> Ash.update(actor: admin)
|
||||||
|
|
||||||
|
assert updated_user.email # Uses scope :all from PermissionSets ✅
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
1. **Don't assume** that returning a filter from `strict_check` will trigger `auto_filter` - test it!
|
||||||
|
2. **Bypass with `expr()` is necessary** for list queries with filter-based permissions
|
||||||
|
3. **Scope concept is NOT redundant** - it's used for operations with records (UPDATE/CREATE/DESTROY)
|
||||||
|
4. **Consistency matters** - following the same pattern across resources improves maintainability
|
||||||
|
5. **Documentation is key** - explaining WHY the pattern exists prevents future confusion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
### If Ash Changes Policy Evaluation
|
||||||
|
|
||||||
|
If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown` or `{:filter, expr}`:
|
||||||
|
|
||||||
|
1. We could **remove** the bypass for READ
|
||||||
|
2. Keep only the HasPermission policy for all operations
|
||||||
|
3. Update tests to verify the new behavior
|
||||||
|
|
||||||
|
**However, for now (Ash 3.13.1), the bypass pattern is necessary and correct.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Ash Policy Documentation**: [https://hexdocs.pm/ash/policies.html](https://hexdocs.pm/ash/policies.html)
|
||||||
|
- **Implementation**: `lib/accounts/user.ex` (lines 271-315)
|
||||||
|
- **Tests**: `test/mv/accounts/user_policies_test.exs`
|
||||||
|
- **Architecture Doc**: `docs/roles-and-permissions-architecture.md`
|
||||||
|
- **Permission Sets**: `lib/mv/authorization/permission_sets.ex`
|
||||||
|
|
@ -871,79 +871,166 @@ end
|
||||||
|
|
||||||
**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins.
|
**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bypass vs. HasPermission: When to Use Which?
|
||||||
|
|
||||||
|
**Key Finding:** For filter-based permissions (`scope :own`, `scope :linked`), we use a **two-tier approach**:
|
||||||
|
|
||||||
|
1. **Bypass with `expr()` for READ** - Handles list queries (auto_filter)
|
||||||
|
2. **HasPermission for UPDATE/CREATE/DESTROY** - Handles operations with records
|
||||||
|
|
||||||
|
### Why This Pattern?
|
||||||
|
|
||||||
|
**The Problem with HasPermission for List Queries:**
|
||||||
|
|
||||||
|
When `HasPermission` returns `{:filter, expr(...)}` for `scope :own` or `scope :linked`:
|
||||||
|
- `strict_check` returns `{:ok, false}` for queries without a record
|
||||||
|
- Ash does **NOT** reliably call `auto_filter` when `strict_check` returns `false`
|
||||||
|
- Result: List queries fail ❌
|
||||||
|
|
||||||
|
**The Solution:**
|
||||||
|
|
||||||
|
Use `bypass` with `expr()` directly for READ operations:
|
||||||
|
- Ash handles `expr()` natively for both `strict_check` and `auto_filter`
|
||||||
|
- List queries work correctly ✅
|
||||||
|
- Single-record reads work correctly ✅
|
||||||
|
|
||||||
|
### Pattern Summary
|
||||||
|
|
||||||
|
| Operation | Has Record? | Use | Why |
|
||||||
|
|-----------|-------------|-----|-----|
|
||||||
|
| **READ (list)** | ❌ No | `bypass` with `expr()` | Triggers auto_filter |
|
||||||
|
| **READ (single)** | ✅ Yes | `bypass` with `expr()` | expr() evaluates to true/false |
|
||||||
|
| **UPDATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record |
|
||||||
|
| **CREATE** | ✅ Yes (changeset) | `HasPermission` | strict_check can evaluate record |
|
||||||
|
| **DESTROY** | ✅ Yes | `HasPermission` | strict_check can evaluate record |
|
||||||
|
|
||||||
|
### Is scope :own/:linked Still Useful?
|
||||||
|
|
||||||
|
**YES! ✅** The scope concept is essential:
|
||||||
|
|
||||||
|
1. **Documentation** - Clearly expresses intent in PermissionSets
|
||||||
|
2. **UPDATE/CREATE/DESTROY** - Works perfectly via HasPermission when record is present
|
||||||
|
3. **Consistency** - All permissions are centralized in PermissionSets
|
||||||
|
4. **Maintainability** - Easy to see what each role can do
|
||||||
|
|
||||||
|
The bypass is a **technical workaround** for Ash's auto_filter limitation, not a replacement for the scope concept.
|
||||||
|
|
||||||
|
### Consistency Across Resources
|
||||||
|
|
||||||
|
Both `User` and `Member` follow this pattern:
|
||||||
|
|
||||||
|
- **User**: Bypass for READ (`id == ^actor(:id)`), HasPermission for UPDATE (`scope :own`)
|
||||||
|
- **Member**: Bypass for READ (`id == ^actor(:member_id)`), HasPermission for UPDATE (`scope :linked`)
|
||||||
|
|
||||||
|
This ensures consistent behavior and predictable authorization logic throughout the application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### User Resource Policies
|
### User Resource Policies
|
||||||
|
|
||||||
**Location:** `lib/mv/accounts/user.ex`
|
**Location:** `lib/accounts/user.ex`
|
||||||
|
|
||||||
**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role.
|
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own).
|
||||||
|
|
||||||
|
**Key Insight:** Bypass with `expr()` is needed ONLY for READ list queries because HasPermission's strict_check cannot properly trigger auto_filter. UPDATE operations work correctly via HasPermission because a changeset with record is available.
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
defmodule Mv.Accounts.User do
|
defmodule Mv.Accounts.User do
|
||||||
use Ash.Resource, ...
|
use Ash.Resource, ...
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
# SPECIAL CASE: Users can always access their own account
|
# 1. AshAuthentication Bypass (registration/login without actor)
|
||||||
# This takes precedence over permission checks
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
policy action_type([:read, :update]) do
|
authorize_if always()
|
||||||
description "Users can always read and update their own account"
|
end
|
||||||
|
|
||||||
|
# 2. NoActor Bypass (test environment only, for test fixtures)
|
||||||
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
|
authorize_if Mv.Authorization.Checks.NoActor
|
||||||
|
end
|
||||||
|
|
||||||
|
# 3. SPECIAL CASE: Users can always READ their own account
|
||||||
|
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
||||||
|
# UPDATE is handled by HasPermission below (scope :own works with changesets)
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# GENERAL: Other operations require permission
|
# 4. GENERAL: Check permissions from user's role
|
||||||
# (e.g., admin reading/updating other users, admin destroying users)
|
# - :own_data → can UPDATE own user (scope :own via HasPermission)
|
||||||
|
# - :read_only → can UPDATE own user (scope :own via HasPermission)
|
||||||
|
# - :normal_user → can UPDATE own user (scope :own via HasPermission)
|
||||||
|
# - :admin → can read/create/update/destroy all users (scope :all)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role"
|
description "Check permissions from user's role and permission set"
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
||||||
# DEFAULT: Forbid if no policy matched
|
# 5. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
|
||||||
forbid_if always()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# ...
|
# ...
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Why Bypass for READ but not UPDATE?**
|
||||||
|
|
||||||
|
- **READ list queries** (`Ash.read(User, actor: user)`): No record at strict_check time → HasPermission returns `{:ok, false}` → auto_filter not called → bypass with `expr()` needed ✅
|
||||||
|
- **UPDATE operations** (`Ash.update(changeset, actor: user)`): Changeset contains record → HasPermission can evaluate `scope :own` correctly → works via HasPermission ✅
|
||||||
|
|
||||||
**Permission Matrix:**
|
**Permission Matrix:**
|
||||||
|
|
||||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||||
|--------|----------|----------|------------|-------------|-------|
|
|--------|----------|----------|------------|-------------|-------|
|
||||||
| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
|
| Read own | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (bypass) | ✅ (scope :all) |
|
||||||
| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ |
|
| Update own | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :own) | ✅ (scope :all) |
|
||||||
| Read others | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Read others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
|
||||||
| Update others | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Update others | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
|
||||||
| Create | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Create | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
|
||||||
| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ (scope :all) |
|
||||||
|
|
||||||
|
**Note:** This pattern is consistent with Member resource policies (bypass for READ, HasPermission for UPDATE).
|
||||||
|
|
||||||
### Member Resource Policies
|
### Member Resource Policies
|
||||||
|
|
||||||
**Location:** `lib/mv/membership/member.ex`
|
**Location:** `lib/mv/membership/member.ex`
|
||||||
|
|
||||||
**Special Case:** Users can always READ their linked member (where `id == user.member_id`).
|
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :linked).
|
||||||
|
|
||||||
|
**Key Insight:** Same pattern as User - bypass with `expr()` is needed ONLY for READ list queries. UPDATE operations work correctly via HasPermission because a changeset with record is available.
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
defmodule Mv.Membership.Member do
|
defmodule Mv.Membership.Member do
|
||||||
use Ash.Resource, ...
|
use Ash.Resource, ...
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
# SPECIAL CASE: Users can always access their linked member
|
# 1. NoActor Bypass (test environment only, for test fixtures)
|
||||||
policy action_type([:read, :update]) do
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
description "Users can access member linked to their account"
|
authorize_if Mv.Authorization.Checks.NoActor
|
||||||
authorize_if expr(user_id == ^actor(:id))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# GENERAL: Check permissions from role
|
# 2. SPECIAL CASE: Users can always READ their linked member
|
||||||
|
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
||||||
|
# UPDATE is handled by HasPermission below (scope :linked works with changesets)
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read member linked to their account"
|
||||||
|
authorize_if expr(id == ^actor(:member_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# 3. GENERAL: Check permissions from role
|
||||||
|
# - :own_data → can UPDATE linked member (scope :linked via HasPermission)
|
||||||
|
# - :read_only → can READ all members (scope :all), no update permission
|
||||||
|
# - :normal_user → can CRUD all members (scope :all)
|
||||||
|
# - :admin → can CRUD all members (scope :all)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role"
|
description "Check permissions from user's role"
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
||||||
# DEFAULT: Forbid
|
# 4. DEFAULT: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
|
||||||
forbid_if always()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Custom validation for email editing (see Special Cases section)
|
# Custom validation for email editing (see Special Cases section)
|
||||||
|
|
@ -957,6 +1044,11 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Why Bypass for READ but not UPDATE?**
|
||||||
|
|
||||||
|
- **READ list queries**: No record at strict_check time → bypass with `expr(id == ^actor(:member_id))` needed for auto_filter ✅
|
||||||
|
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :linked` correctly ✅
|
||||||
|
|
||||||
**Permission Matrix:**
|
**Permission Matrix:**
|
||||||
|
|
||||||
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin |
|
||||||
|
|
@ -1652,17 +1744,21 @@ end
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
|
|
||||||
Policy in `User` resource places this check BEFORE the general `HasPermission` check:
|
Policy in `User` resource uses a two-tier approach:
|
||||||
|
- **READ**: Bypass with `expr()` for list queries (auto_filter)
|
||||||
|
- **UPDATE**: HasPermission with `scope :own` (evaluates PermissionSets)
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
policies do
|
policies do
|
||||||
# SPECIAL CASE: Takes precedence over role permissions
|
# SPECIAL CASE: Users can always READ their own account
|
||||||
policy action_type([:read, :update]) do
|
# Bypass needed for list queries (expr() triggers auto_filter in Ash)
|
||||||
description "Users can always read and update their own account"
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# GENERAL: For other operations (e.g., admin reading other users)
|
# GENERAL: Check permissions from user's role
|
||||||
|
# UPDATE uses scope :own from PermissionSets (all sets grant User.update :own)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
@ -1670,10 +1766,53 @@ end
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why this works:**
|
**Why this works:**
|
||||||
- Ash evaluates policies top-to-bottom
|
- READ bypass handles list queries correctly (auto_filter)
|
||||||
- First matching policy wins
|
- UPDATE is handled by HasPermission with `scope :own` from PermissionSets
|
||||||
- Special case catches own-account access before checking permissions
|
- All permission sets (`:own_data`, `:read_only`, `:normal_user`, `:admin`) grant `User.update :own`
|
||||||
- Even a user with `own_data` (no admin permissions) can update their credentials
|
- Even a user with `read_only` (read-only for member data) can update their own credentials
|
||||||
|
|
||||||
|
**Important:** UPDATE is NOT an unverrückbarer Spezialfall (hardcoded bypass). It is controlled by PermissionSets. If a permission set is changed to remove `User.update :own`, users with that set will lose the ability to update their credentials. See "User Credentials: Why read_only Can Still Update" below for details.
|
||||||
|
|
||||||
|
### 1a. User Credentials: Why read_only Can Still Update
|
||||||
|
|
||||||
|
**Question:** If `read_only` means "read-only", why can users with this permission set still update their own credentials?
|
||||||
|
|
||||||
|
**Answer:** The `read_only` permission set refers to **member data**, NOT user credentials. All permission sets grant `User.update :own` to allow password changes and profile updates.
|
||||||
|
|
||||||
|
**Implementation Details:**
|
||||||
|
|
||||||
|
1. **UPDATE is controlled by PermissionSets**, not a hardcoded bypass
|
||||||
|
2. **All 4 permission sets** (`:own_data`, `:read_only`, `:normal_user`, `:admin`) explicitly grant:
|
||||||
|
```elixir
|
||||||
|
%{resource: "User", action: :update, scope: :own, granted: true}
|
||||||
|
```
|
||||||
|
3. **HasPermission** evaluates `scope :own` for UPDATE operations (when a changeset with record is present)
|
||||||
|
4. **No special bypass** is needed for UPDATE - it works correctly via HasPermission
|
||||||
|
|
||||||
|
**Why This Design?**
|
||||||
|
|
||||||
|
- **Flexibility:** Permission sets can be modified to change UPDATE behavior
|
||||||
|
- **Consistency:** All permissions are centralized in PermissionSets
|
||||||
|
- **Clarity:** The name "read_only" refers to member data, not user credentials
|
||||||
|
- **Maintainability:** Easy to see what each role can do in PermissionSets module
|
||||||
|
|
||||||
|
**Warning:** If a permission set is changed to remove `User.update :own`, users with that set will **lose the ability to update their credentials**. This is intentional - UPDATE is controlled by PermissionSets, not hardcoded.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```elixir
|
||||||
|
# In PermissionSets.get_permissions(:read_only)
|
||||||
|
resources: [
|
||||||
|
# User: Can read/update own credentials only
|
||||||
|
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||||
|
# All permission sets grant User.update :own to allow password changes.
|
||||||
|
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||||
|
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||||
|
|
||||||
|
# Member: Can read all members, no modifications
|
||||||
|
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||||
|
# Note: No Member.update permission - this is the "read_only" part
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### 2. Linked Member Email Editing
|
### 2. Linked Member Email Editing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -524,61 +524,68 @@ Add authorization policies to the Member resource using the new `HasPermission`
|
||||||
**Size:** M (2 days)
|
**Size:** M (2 days)
|
||||||
**Dependencies:** #6 (HasPermission check)
|
**Dependencies:** #6 (HasPermission check)
|
||||||
**Can work in parallel:** Yes (parallel with #7, #9, #10)
|
**Can work in parallel:** Yes (parallel with #7, #9, #10)
|
||||||
**Assignable to:** Backend Developer
|
**Assignable to:** Backend Developer
|
||||||
|
**Status:** ✅ **COMPLETED**
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
|
|
||||||
Add authorization policies to the User resource. Special case: Users can always read/update their own credentials.
|
Add authorization policies to the User resource. Users can always read their own credentials (via bypass), and update their own credentials (via HasPermission with scope :own).
|
||||||
|
|
||||||
|
**Implementation Pattern:**
|
||||||
|
|
||||||
|
Following the same pattern as Member resource:
|
||||||
|
- **Bypass for READ** - Handles list queries (auto_filter)
|
||||||
|
- **HasPermission for UPDATE** - Handles updates with scope :own
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
|
|
||||||
1. Open `lib/mv/accounts/user.ex`
|
1. ✅ Open `lib/accounts/user.ex`
|
||||||
2. Add `policies` block
|
2. ✅ Add `policies` block
|
||||||
3. Add special policy: Allow user to always access their own account (before general policy)
|
3. ✅ Add AshAuthentication bypass (registration/login without actor)
|
||||||
|
4. ✅ Add NoActor bypass (test environment only)
|
||||||
|
5. ✅ Add bypass for READ: Allow user to always read their own account
|
||||||
```elixir
|
```elixir
|
||||||
policy action_type([:read, :update]) do
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
4. Add general policy: Check HasPermission for all actions
|
6. ✅ Add general policy: Check HasPermission for all actions (including UPDATE with scope :own)
|
||||||
5. Ensure :destroy is admin-only (via HasPermission)
|
7. ✅ Ensure :destroy is admin-only (via HasPermission)
|
||||||
6. Preload :role relationship for actor
|
8. ✅ Preload :role relationship for actor in tests
|
||||||
|
|
||||||
**Policy Order:**
|
**Policy Order:**
|
||||||
1. Allow user to read/update own account (id == actor.id)
|
1. ✅ AshAuthentication bypass (registration/login)
|
||||||
2. Check HasPermission (for admin operations)
|
2. ✅ NoActor bypass (test environment)
|
||||||
3. Default: Forbid
|
3. ✅ Bypass: User can READ own account (id == actor.id)
|
||||||
|
4. ✅ HasPermission: General permission check (UPDATE uses scope :own, admin uses scope :all)
|
||||||
|
5. ✅ Default: Ash implicitly forbids (fail-closed)
|
||||||
|
|
||||||
|
**Why Bypass for READ but not UPDATE?**
|
||||||
|
|
||||||
|
- **READ list queries**: No record at strict_check time → bypass with `expr()` needed for auto_filter ✅
|
||||||
|
- **UPDATE operations**: Changeset contains record → HasPermission evaluates `scope :own` correctly ✅
|
||||||
|
|
||||||
|
This ensures `scope :own` in PermissionSets is actually used (not redundant).
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
- [ ] User can always read/update own credentials
|
- ✅ User can always read own credentials (via bypass)
|
||||||
- [ ] Only admin can read/update other users
|
- ✅ User can always update own credentials (via HasPermission with scope :own)
|
||||||
- [ ] Only admin can destroy users
|
- ✅ Only admin can read/update other users (scope :all)
|
||||||
- [ ] Policy order is correct
|
- ✅ Only admin can destroy users (scope :all)
|
||||||
- [ ] Actor preloads :role relationship
|
- ✅ Policy order is correct (AshAuth → NoActor → Bypass READ → HasPermission)
|
||||||
|
- ✅ Actor preloads :role relationship
|
||||||
|
- ✅ All tests pass (30/31 pass, 1 skipped)
|
||||||
|
|
||||||
**Test Strategy (TDD):**
|
**Test Results:**
|
||||||
|
|
||||||
**Own Data Tests (All Roles):**
|
|
||||||
- User with :own_data can read own user record
|
|
||||||
- User with :own_data can update own email/password
|
|
||||||
- User with :own_data cannot read other users
|
|
||||||
- User with :read_only can read own data
|
|
||||||
- User with :normal_user can read own data
|
|
||||||
- Verify special policy takes precedence
|
|
||||||
|
|
||||||
**Admin Tests:**
|
|
||||||
- Admin can read all users
|
|
||||||
- Admin can update any user's credentials
|
|
||||||
- Admin can destroy users
|
|
||||||
- Admin has unrestricted access
|
|
||||||
|
|
||||||
**Forbidden Tests:**
|
|
||||||
- Non-admin cannot read other users
|
|
||||||
- Non-admin cannot update other users
|
|
||||||
- Non-admin cannot destroy users
|
|
||||||
|
|
||||||
**Test File:** `test/mv/accounts/user_policies_test.exs`
|
**Test File:** `test/mv/accounts/user_policies_test.exs`
|
||||||
|
- ✅ 31 tests total: 30 passing, 1 skipped (AshAuthentication edge case)
|
||||||
|
- ✅ Tests for all 4 permission sets: own_data, read_only, normal_user, admin
|
||||||
|
- ✅ Tests for AshAuthentication bypass (registration/login)
|
||||||
|
- ✅ Tests for NoActor bypass (test environment)
|
||||||
|
- ✅ Tests verify scope :own is used for UPDATE (not redundant)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
274
docs/user-resource-policies-implementation-summary.md
Normal file
274
docs/user-resource-policies-implementation-summary.md
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# User Resource Authorization Policies - Implementation Summary
|
||||||
|
|
||||||
|
**Date:** 2026-01-22
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Successfully implemented authorization policies for the User resource following the Bypass + HasPermission pattern, ensuring consistency with Member resource policies and proper use of the scope concept from PermissionSets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### 1. Policy Structure in `lib/accounts/user.ex`
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
policies do
|
||||||
|
# 1. AshAuthentication Bypass
|
||||||
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
|
||||||
|
# 2. NoActor Bypass (test environment only)
|
||||||
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
|
authorize_if Mv.Authorization.Checks.NoActor
|
||||||
|
end
|
||||||
|
|
||||||
|
# 3. Bypass for READ (list queries via auto_filter)
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# 4. HasPermission for all operations (uses scope from PermissionSets)
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from user's role and permission set"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Suite in `test/mv/accounts/user_policies_test.exs`
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- ✅ 31 tests total: 30 passing, 1 skipped
|
||||||
|
- ✅ All 4 permission sets tested: `own_data`, `read_only`, `normal_user`, `admin`
|
||||||
|
- ✅ READ operations (list and single record)
|
||||||
|
- ✅ UPDATE operations (own and other users)
|
||||||
|
- ✅ CREATE operations (admin only)
|
||||||
|
- ✅ DESTROY operations (admin only)
|
||||||
|
- ✅ AshAuthentication bypass (registration/login)
|
||||||
|
- ✅ NoActor bypass (test environment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Decision 1: Bypass for READ, HasPermission for UPDATE
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- READ list queries have no record at `strict_check` time
|
||||||
|
- `HasPermission` returns `{:ok, false}` for queries without record
|
||||||
|
- Ash doesn't call `auto_filter` when `strict_check` returns `false`
|
||||||
|
- `expr()` in bypass is handled natively by Ash for `auto_filter`
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Bypass handles READ list queries ✅
|
||||||
|
- HasPermission handles UPDATE with `scope :own` ✅
|
||||||
|
- No redundancy - both are necessary ✅
|
||||||
|
|
||||||
|
### Decision 2: No Explicit `forbid_if always()`
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Ash implicitly forbids if no policy authorizes (fail-closed by default)
|
||||||
|
- Explicit `forbid_if always()` at the end breaks tests
|
||||||
|
- It would forbid valid operations that should be authorized by previous policies
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Policies rely on Ash's implicit forbid ✅
|
||||||
|
- Tests pass with this approach ✅
|
||||||
|
|
||||||
|
### Decision 3: Consistency with Member Resource
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Member resource uses same pattern: Bypass for READ, HasPermission for UPDATE
|
||||||
|
- Consistent patterns improve maintainability and predictability
|
||||||
|
- Developers can understand authorization logic across resources
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- User and Member follow identical pattern ✅
|
||||||
|
- Authorization logic is consistent throughout the app ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Scope Concept Is NOT Redundant
|
||||||
|
|
||||||
|
### Initial Concern
|
||||||
|
|
||||||
|
> "If we use a bypass with `expr(id == ^actor(:id))` for READ, isn't `scope :own` in PermissionSets redundant?"
|
||||||
|
|
||||||
|
### Resolution
|
||||||
|
|
||||||
|
**NO! The scope concept is essential:**
|
||||||
|
|
||||||
|
1. **Documentation** - `scope :own` clearly expresses intent in PermissionSets
|
||||||
|
2. **UPDATE operations** - `scope :own` is USED by HasPermission when changeset contains record
|
||||||
|
3. **Admin operations** - `scope :all` allows admins full access
|
||||||
|
4. **Maintainability** - All permissions centralized in one place
|
||||||
|
|
||||||
|
**Test Proof:**
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
test "can update own email", %{user: user} do
|
||||||
|
# This works via HasPermission with scope :own (NOT bypass)
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: "new@example.com"})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email # ✅ Proves scope :own is used
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
### 1. Created `docs/policy-bypass-vs-haspermission.md`
|
||||||
|
|
||||||
|
Comprehensive documentation explaining:
|
||||||
|
- Why bypass is needed for READ
|
||||||
|
- Why HasPermission works for UPDATE
|
||||||
|
- Technical deep dive into Ash policy evaluation
|
||||||
|
- Test coverage proving the pattern
|
||||||
|
- Lessons learned
|
||||||
|
|
||||||
|
### 2. Updated `docs/roles-and-permissions-architecture.md`
|
||||||
|
|
||||||
|
- Added "Bypass vs. HasPermission: When to Use Which?" section
|
||||||
|
- Updated User Resource Policies section with correct implementation
|
||||||
|
- Updated Member Resource Policies section for consistency
|
||||||
|
- Added pattern comparison table
|
||||||
|
|
||||||
|
### 3. Updated `docs/roles-and-permissions-implementation-plan.md`
|
||||||
|
|
||||||
|
- Marked Issue #8 as COMPLETED ✅
|
||||||
|
- Added implementation details
|
||||||
|
- Documented why bypass is needed
|
||||||
|
- Added test results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
### All Relevant Tests Pass
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix test test/mv/accounts/user_policies_test.exs \
|
||||||
|
test/mv/authorization/checks/has_permission_test.exs \
|
||||||
|
test/mv/membership/member_policies_test.exs
|
||||||
|
|
||||||
|
# Results:
|
||||||
|
# 75 tests: 74 passing, 1 skipped
|
||||||
|
# ✅ User policies: 30/31 (1 skipped)
|
||||||
|
# ✅ HasPermission check: 21/21
|
||||||
|
# ✅ Member policies: 23/23
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Test Coverage
|
||||||
|
|
||||||
|
**Own Data Access (All Roles):**
|
||||||
|
- ✅ Can read own user record (via bypass)
|
||||||
|
- ✅ Can update own email (via HasPermission with scope :own)
|
||||||
|
- ✅ Cannot read other users (filtered by bypass)
|
||||||
|
- ✅ Cannot update other users (forbidden by HasPermission)
|
||||||
|
- ✅ List returns only own user (auto_filter via bypass)
|
||||||
|
|
||||||
|
**Admin Access:**
|
||||||
|
- ✅ Can read all users (HasPermission with scope :all)
|
||||||
|
- ✅ Can update other users (HasPermission with scope :all)
|
||||||
|
- ✅ Can create users (HasPermission with scope :all)
|
||||||
|
- ✅ Can destroy users (HasPermission with scope :all)
|
||||||
|
|
||||||
|
**AshAuthentication:**
|
||||||
|
- ✅ Registration works without actor
|
||||||
|
- ✅ OIDC registration works
|
||||||
|
- ✅ OIDC sign-in works
|
||||||
|
|
||||||
|
**Test Environment:**
|
||||||
|
- ✅ Operations without actor work in test environment
|
||||||
|
- ✅ NoActor bypass correctly detects compile-time environment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
1. ✅ `lib/accounts/user.ex` - Added policies block (lines 271-315)
|
||||||
|
2. ✅ `lib/mv/authorization/checks/has_permission.ex` - Added User resource support in `evaluate_filter_for_strict_check`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
3. ✅ `test/mv/accounts/user_policies_test.exs` - Created comprehensive test suite (435 lines)
|
||||||
|
4. ✅ `test/mv/authorization/checks/has_permission_test.exs` - Updated to expect `false` instead of `:unknown`
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
5. ✅ `docs/policy-bypass-vs-haspermission.md` - New comprehensive guide (created)
|
||||||
|
6. ✅ `docs/roles-and-permissions-architecture.md` - Updated User and Member sections
|
||||||
|
7. ✅ `docs/roles-and-permissions-implementation-plan.md` - Marked Issue #8 as completed
|
||||||
|
8. ✅ `docs/user-resource-policies-implementation-summary.md` - This file (created)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
### 1. Test Before Assuming
|
||||||
|
|
||||||
|
The initial plan assumed HasPermission with `scope :own` would be sufficient. Testing revealed that Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check` returns `false` or `:unknown`.
|
||||||
|
|
||||||
|
### 2. Bypass Is Not a Workaround, It's a Pattern
|
||||||
|
|
||||||
|
The bypass with `expr()` is not a hack or workaround - it's the **correct pattern** for filter-based authorization in Ash when dealing with list queries.
|
||||||
|
|
||||||
|
### 3. Scope Concept Remains Essential
|
||||||
|
|
||||||
|
Even with bypass for READ, the scope concept in PermissionSets is essential for:
|
||||||
|
- UPDATE/CREATE/DESTROY operations
|
||||||
|
- Documentation and maintainability
|
||||||
|
- Centralized permission management
|
||||||
|
|
||||||
|
### 4. Consistency Across Resources
|
||||||
|
|
||||||
|
Following the same pattern (Bypass for READ, HasPermission for UPDATE) across User and Member resources makes the codebase more maintainable and predictable.
|
||||||
|
|
||||||
|
### 5. Documentation Is Key
|
||||||
|
|
||||||
|
Thorough documentation explaining **WHY** the pattern exists prevents future confusion and ensures the pattern is applied correctly in future resources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
### If Adding New Resources with Filter-Based Permissions
|
||||||
|
|
||||||
|
Follow the same pattern:
|
||||||
|
1. Bypass with `expr()` for READ (list queries)
|
||||||
|
2. HasPermission for UPDATE/CREATE/DESTROY (uses scope from PermissionSets)
|
||||||
|
3. Define appropriate scopes in PermissionSets (`:own`, `:linked`, `:all`)
|
||||||
|
|
||||||
|
### If Ash Framework Changes
|
||||||
|
|
||||||
|
If a future version of Ash reliably calls `auto_filter` when `strict_check` returns `:unknown`:
|
||||||
|
1. Consider removing bypass for READ
|
||||||
|
2. Keep only HasPermission policy
|
||||||
|
3. Update tests to verify new behavior
|
||||||
|
4. Update documentation
|
||||||
|
|
||||||
|
**For now (Ash 3.13.1), the current pattern is correct and necessary.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
✅ **User Resource Authorization Policies are fully implemented, tested, and documented.**
|
||||||
|
|
||||||
|
The implementation:
|
||||||
|
- Follows best practices for Ash policies
|
||||||
|
- Is consistent with Member resource pattern
|
||||||
|
- Uses the scope concept from PermissionSets effectively
|
||||||
|
- Has comprehensive test coverage
|
||||||
|
- Is thoroughly documented for future developers
|
||||||
|
|
||||||
|
**Status: PRODUCTION READY** 🎉
|
||||||
|
|
@ -5,9 +5,8 @@ defmodule Mv.Accounts.User do
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Accounts,
|
domain: Mv.Accounts,
|
||||||
data_layer: AshPostgres.DataLayer,
|
data_layer: AshPostgres.DataLayer,
|
||||||
extensions: [AshAuthentication]
|
extensions: [AshAuthentication],
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
# authorizers: [Ash.Policy.Authorizer]
|
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "users"
|
table "users"
|
||||||
|
|
@ -267,6 +266,36 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Authorization Policies
|
||||||
|
# Order matters: Most specific policies first, then general permission check
|
||||||
|
policies do
|
||||||
|
# AshAuthentication bypass (registration/login without actor)
|
||||||
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
|
description "Allow AshAuthentication internal operations (registration, login)"
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
|
||||||
|
# NoActor bypass (test fixtures only, see no_actor.ex)
|
||||||
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
|
description "Allow system operations without actor (test environment only)"
|
||||||
|
authorize_if Mv.Authorization.Checks.NoActor
|
||||||
|
end
|
||||||
|
|
||||||
|
# READ bypass for list queries (scope :own via expr)
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read their own account"
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from user's role and permission set"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
|
||||||
|
# Default: Ash implicitly forbids if no policy authorizes (fail-closed)
|
||||||
|
end
|
||||||
|
|
||||||
# Global validations - applied to all relevant actions
|
# Global validations - applied to all relevant actions
|
||||||
validations do
|
validations do
|
||||||
# Password strength policy: minimum 8 characters for all password-related actions
|
# Password strength policy: minimum 8 characters for all password-related actions
|
||||||
|
|
|
||||||
99
lib/mv/authorization/actor.ex
Normal file
99
lib/mv/authorization/actor.ex
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
defmodule Mv.Authorization.Actor do
|
||||||
|
@moduledoc """
|
||||||
|
Helper functions for ensuring User actors have required data loaded.
|
||||||
|
|
||||||
|
## Actor Invariant
|
||||||
|
|
||||||
|
Authorization policies (especially HasPermission) require that the User actor
|
||||||
|
has their `:role` relationship loaded. This module provides helpers to
|
||||||
|
ensure this invariant is maintained across all entry points:
|
||||||
|
|
||||||
|
- LiveView on_mount hooks
|
||||||
|
- Plug pipelines
|
||||||
|
- Background jobs
|
||||||
|
- Tests
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This module ONLY handles `Mv.Accounts.User` resources. Other resources with
|
||||||
|
a `:role` field are ignored (returned as-is). This prevents accidental
|
||||||
|
authorization bypasses and keeps the logic focused.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
# In LiveView on_mount
|
||||||
|
def ensure_user_role_loaded(_name, socket) do
|
||||||
|
user = Actor.ensure_loaded(socket.assigns[:current_user])
|
||||||
|
assign(socket, :current_user, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# In tests
|
||||||
|
user = Actor.ensure_loaded(user)
|
||||||
|
|
||||||
|
## Security Note
|
||||||
|
|
||||||
|
`ensure_loaded/1` loads the role with `authorize?: false` to avoid circular
|
||||||
|
dependency (actor needs role loaded to be authorized, but loading role requires
|
||||||
|
authorization). This is safe because:
|
||||||
|
|
||||||
|
- The actor (User) is loading their OWN role (user.role relationship)
|
||||||
|
- This load is needed FOR authorization checks to work
|
||||||
|
- The role itself contains no sensitive data (just permission_set reference)
|
||||||
|
- The actor is already authenticated (passed auth boundary)
|
||||||
|
|
||||||
|
Alternative would be to denormalize permission_set_name on User, but that
|
||||||
|
adds complexity and potential for inconsistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Ensures the actor (User) has their `:role` relationship loaded.
|
||||||
|
|
||||||
|
- If actor is nil, returns nil
|
||||||
|
- If role is already loaded, returns actor as-is
|
||||||
|
- If role is %Ash.NotLoaded{}, loads it and returns updated actor
|
||||||
|
- If actor is not a User, returns as-is (no-op)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Actor.ensure_loaded(nil)
|
||||||
|
nil
|
||||||
|
|
||||||
|
iex> Actor.ensure_loaded(%User{role: %Role{}})
|
||||||
|
%User{role: %Role{}}
|
||||||
|
|
||||||
|
iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}})
|
||||||
|
%User{role: %Role{}} # role loaded
|
||||||
|
"""
|
||||||
|
def ensure_loaded(nil), do: nil
|
||||||
|
|
||||||
|
# Only handle Mv.Accounts.User - clear intention, no accidental other resources
|
||||||
|
def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do
|
||||||
|
load_role(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_loaded(actor), do: actor
|
||||||
|
|
||||||
|
defp load_role(actor) do
|
||||||
|
# SECURITY: We skip authorization here because this is a bootstrap scenario:
|
||||||
|
# - The actor is loading their OWN role (actor.role relationship)
|
||||||
|
# - This load is needed FOR authorization checks to work (circular dependency)
|
||||||
|
# - The role itself contains no sensitive data (just permission_set reference)
|
||||||
|
# - The actor is already authenticated (passed auth boundary)
|
||||||
|
# Alternative would be to denormalize permission_set_name on User.
|
||||||
|
case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do
|
||||||
|
{:ok, loaded_actor} ->
|
||||||
|
loaded_actor
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
# Log error but don't crash - fail-closed for authorization
|
||||||
|
Logger.warning(
|
||||||
|
"Failed to load actor role: #{inspect(error)}. " <>
|
||||||
|
"Authorization may fail if role is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
actor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -8,10 +8,37 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
3. Finds matching permission for current resource + action
|
3. Finds matching permission for current resource + action
|
||||||
4. Applies scope filter (:own, :linked, :all)
|
4. Applies scope filter (:own, :linked, :all)
|
||||||
|
|
||||||
|
## Important: strict_check Behavior
|
||||||
|
|
||||||
|
For filter-based scopes (`:own`, `:linked`):
|
||||||
|
- **WITH record**: Evaluates filter against record (returns `true`/`false`)
|
||||||
|
- **WITHOUT record** (queries/lists): Returns `false`
|
||||||
|
|
||||||
|
**Why `false` instead of `:unknown`?**
|
||||||
|
|
||||||
|
Ash's policy evaluation doesn't reliably call `auto_filter` when `strict_check`
|
||||||
|
returns `:unknown`. To ensure list queries work correctly, resources **MUST** use
|
||||||
|
bypass policies with `expr()` for READ operations (see `docs/policy-bypass-vs-haspermission.md`).
|
||||||
|
|
||||||
|
This means `HasPermission` is **NOT** generically reusable for query authorization
|
||||||
|
with filter scopes - it requires companion bypass policies.
|
||||||
|
|
||||||
|
## Usage Pattern
|
||||||
|
|
||||||
|
See `docs/policy-bypass-vs-haspermission.md` for the two-tier pattern:
|
||||||
|
- **READ**: `bypass` with `expr()` (handles auto_filter)
|
||||||
|
- **UPDATE/CREATE/DESTROY**: `HasPermission` (handles scope evaluation)
|
||||||
|
|
||||||
## Usage in Ash Resource
|
## Usage in Ash Resource
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
policy action_type(:read) do
|
# READ: Bypass for list queries
|
||||||
|
bypass action_type(:read) do
|
||||||
|
authorize_if expr(id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# UPDATE: HasPermission for scope evaluation
|
||||||
|
policy action_type([:update, :create, :destroy]) do
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -34,6 +61,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
|
|
||||||
All errors result in Forbidden (policy fails).
|
All errors result in Forbidden (policy fails).
|
||||||
|
|
||||||
|
## Role Loading Fallback
|
||||||
|
|
||||||
|
If the actor's `:role` relationship is `%Ash.NotLoaded{}`, this check will
|
||||||
|
attempt to load it automatically. This provides a fallback if `on_mount` hooks
|
||||||
|
didn't run (e.g., in non-LiveView contexts).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
# In a resource policy
|
# In a resource policy
|
||||||
|
|
@ -83,6 +116,9 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
|
|
||||||
# Helper function to reduce nesting depth
|
# Helper function to reduce nesting depth
|
||||||
defp strict_check_with_permissions(actor, resource, action, record) do
|
defp strict_check_with_permissions(actor, resource, action, record) do
|
||||||
|
# Ensure role is loaded (fallback if on_mount didn't run)
|
||||||
|
actor = ensure_role_loaded(actor)
|
||||||
|
|
||||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
||||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
permissions <- PermissionSets.get_permissions(ps_atom),
|
permissions <- PermissionSets.get_permissions(ps_atom),
|
||||||
|
|
@ -95,11 +131,25 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
resource_name
|
resource_name
|
||||||
) do
|
) do
|
||||||
:authorized ->
|
:authorized ->
|
||||||
|
# For :all scope, authorize directly
|
||||||
{:ok, true}
|
{:ok, true}
|
||||||
|
|
||||||
{:filter, filter_expr} ->
|
{:filter, filter_expr} ->
|
||||||
# For strict_check on single records, evaluate the filter against the record
|
# For :own/:linked scope:
|
||||||
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
# - With a record, evaluate filter against record for strict authorization
|
||||||
|
# - Without a record (queries/lists), return false
|
||||||
|
#
|
||||||
|
# NOTE: Returning false here forces the use of expr-based bypass policies.
|
||||||
|
# This is necessary because Ash's policy evaluation doesn't reliably call auto_filter
|
||||||
|
# when strict_check returns :unknown. Instead, resources should use bypass policies
|
||||||
|
# with expr() directly for filter-based authorization (see User resource).
|
||||||
|
if record do
|
||||||
|
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||||
|
else
|
||||||
|
# No record yet (e.g., read/list queries) - deny at strict_check level
|
||||||
|
# Resources must use expr-based bypass policies for list filtering
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
|
||||||
false ->
|
false ->
|
||||||
{:ok, false}
|
{:ok, false}
|
||||||
|
|
@ -224,9 +274,18 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Evaluate filter expression for strict_check on single records
|
# Evaluate filter expression for strict_check on single records
|
||||||
|
# For :own scope with User resource: id == actor.id
|
||||||
# For :linked scope with Member resource: id == actor.member_id
|
# For :linked scope with Member resource: id == actor.member_id
|
||||||
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||||
case {resource_name, record} do
|
case {resource_name, record} do
|
||||||
|
{"User", %{id: user_id}} when not is_nil(user_id) ->
|
||||||
|
# Check if this user's ID matches the actor's ID (scope :own)
|
||||||
|
if user_id == actor.id do
|
||||||
|
{:ok, true}
|
||||||
|
else
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
|
||||||
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||||
# Check if this member's ID matches the actor's member_id
|
# Check if this member's ID matches the actor's member_id
|
||||||
if member_id == actor.member_id do
|
if member_id == actor.member_id do
|
||||||
|
|
@ -330,4 +389,10 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
defp get_resource_name_for_logging(_resource) do
|
defp get_resource_name_for_logging(_resource) do
|
||||||
"unknown"
|
"unknown"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Fallback: Load role if not loaded (in case on_mount didn't run)
|
||||||
|
# Delegates to centralized Actor helper
|
||||||
|
defp ensure_role_loaded(actor) do
|
||||||
|
Mv.Authorization.Actor.ensure_loaded(actor)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@ defmodule Mv.Authorization.Checks.NoActor do
|
||||||
use Ash.Policy.SimpleCheck
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
# Compile-time check: Only allow no-actor bypass in test environment
|
# Compile-time check: Only allow no-actor bypass in test environment
|
||||||
@allow_no_actor_bypass Mix.env() == :test
|
# SECURITY: This must ONLY be true in test.exs, never in prod/dev
|
||||||
# Alternative (if you want to control via config):
|
# Using compile_env instead of Mix.env() for release-safety
|
||||||
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
|
@allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(_opts) do
|
def describe(_opts) do
|
||||||
|
|
@ -58,13 +58,9 @@ defmodule Mv.Authorization.Checks.NoActor do
|
||||||
@impl true
|
@impl true
|
||||||
def match?(nil, _context, _opts) do
|
def match?(nil, _context, _opts) do
|
||||||
# Actor is nil
|
# Actor is nil
|
||||||
if @allow_no_actor_bypass do
|
# SECURITY: Only allow if compile_env flag is set (test.exs only)
|
||||||
# Test environment: Allow all operations
|
# No runtime Mix.env() check - fail-closed by default (false)
|
||||||
true
|
@allow_no_actor_bypass
|
||||||
else
|
|
||||||
# Production/dev: Deny all operations (fail-closed for security)
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def match?(_actor, _context, _opts) do
|
def match?(_actor, _context, _opts) do
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,9 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
def get_permissions(:own_data) do
|
def get_permissions(:own_data) do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources: [
|
||||||
# User: Can always read/update own credentials
|
# User: Can read/update own credentials only
|
||||||
|
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||||
|
# All permission sets grant User.update :own to allow password changes.
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||||
|
|
||||||
|
|
@ -125,6 +127,8 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources: [
|
||||||
# User: Can read/update own credentials only
|
# User: Can read/update own credentials only
|
||||||
|
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||||
|
# All permission sets grant User.update :own to allow password changes.
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||||
|
|
||||||
|
|
@ -157,6 +161,8 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{
|
%{
|
||||||
resources: [
|
resources: [
|
||||||
# User: Can read/update own credentials only
|
# User: Can read/update own credentials only
|
||||||
|
# IMPORTANT: "read_only" refers to member data, NOT user credentials.
|
||||||
|
# All permission sets grant User.update :own to allow password changes.
|
||||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ defmodule MvWeb do
|
||||||
quote do
|
quote do
|
||||||
use Phoenix.LiveView
|
use Phoenix.LiveView
|
||||||
|
|
||||||
on_mount MvWeb.LiveHelpers
|
on_mount {MvWeb.LiveHelpers, :default}
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
unquote(html_helpers())
|
unquote(html_helpers())
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
import Ash.Query
|
import Ash.Query
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
|
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
|
|
||||||
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
import MvWeb.RoleLive.Helpers,
|
import MvWeb.RoleLive.Helpers,
|
||||||
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
actor = socket.assigns[:current_user]
|
actor = socket.assigns[:current_user]
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
import MvWeb.RoleLive.Helpers,
|
import MvWeb.RoleLive.Helpers,
|
||||||
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
try do
|
try do
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ defmodule MvWeb.UserLive.Index do
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
import MvWeb.TableComponents
|
import MvWeb.TableComponents
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ defmodule MvWeb.UserLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -27,39 +27,17 @@ defmodule MvWeb.LiveHelpers do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_user_role_loaded(socket) do
|
defp ensure_user_role_loaded(socket) do
|
||||||
if socket.assigns[:current_user] do
|
user = socket.assigns[:current_user]
|
||||||
user = socket.assigns.current_user
|
|
||||||
user_with_role = load_user_role(user)
|
if user do
|
||||||
|
# Use centralized Actor helper to ensure role is loaded
|
||||||
|
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
|
||||||
assign(socket, :current_user, user_with_role)
|
assign(socket, :current_user, user_with_role)
|
||||||
else
|
else
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_user_role(user) do
|
|
||||||
case Map.get(user, :role) do
|
|
||||||
%Ash.NotLoaded{} -> load_role_safely(user)
|
|
||||||
nil -> load_role_safely(user)
|
|
||||||
_role -> user
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_role_safely(user) do
|
|
||||||
# Use self as actor for loading own role relationship
|
|
||||||
opts = [domain: Mv.Accounts, actor: user]
|
|
||||||
|
|
||||||
case Ash.load(user, :role, opts) do
|
|
||||||
{:ok, loaded_user} ->
|
|
||||||
loaded_user
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
# Log warning if role loading fails - this can cause authorization issues
|
|
||||||
require Logger
|
|
||||||
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
|
|
||||||
user
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Helper function to get the current actor (user) from socket assigns.
|
Helper function to get the current actor (user) from socket assigns.
|
||||||
|
|
||||||
|
|
|
||||||
424
test/mv/accounts/user_policies_test.exs
Normal file
424
test/mv/accounts/user_policies_test.exs
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for User resource authorization policies.
|
||||||
|
|
||||||
|
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
|
||||||
|
and verifies that policies correctly enforce access control based on
|
||||||
|
user roles and permission sets.
|
||||||
|
"""
|
||||||
|
# async: false because we need database commits to be visible across queries
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
# Helper to create a role with a specific permission set
|
||||||
|
defp create_role_with_permission_set(permission_set_name) do
|
||||||
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
case Authorization.create_role(%{
|
||||||
|
name: role_name,
|
||||||
|
description: "Test role for #{permission_set_name}",
|
||||||
|
permission_set_name: permission_set_name
|
||||||
|
}) do
|
||||||
|
{:ok, role} -> role
|
||||||
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a user with a specific permission set
|
||||||
|
# Returns user with role preloaded (required for authorization)
|
||||||
|
defp create_user_with_permission_set(permission_set_name) do
|
||||||
|
# Create role with permission set
|
||||||
|
role = create_role_with_permission_set(permission_set_name)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Assign role to user
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Reload user with role preloaded (critical for authorization!)
|
||||||
|
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||||
|
user_with_role
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create another user (for testing access to other users)
|
||||||
|
defp create_other_user do
|
||||||
|
create_user_with_permission_set("own_data")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared test setup for permission sets with scope :own access
|
||||||
|
defp setup_user_with_own_access(permission_set) do
|
||||||
|
user = create_user_with_permission_set(permission_set)
|
||||||
|
other_user = create_other_user()
|
||||||
|
|
||||||
|
# Reload user to ensure role is preloaded
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
|
||||||
|
%{user: user, other_user: other_user}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "own_data permission set (Mitglied)" do
|
||||||
|
setup do
|
||||||
|
setup_user_with_own_access("own_data")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read own user record", %{user: user} do
|
||||||
|
{:ok, fetched_user} =
|
||||||
|
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
assert fetched_user.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update own email", %{user: user} do
|
||||||
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||||
|
user: user,
|
||||||
|
other_user: other_user
|
||||||
|
} do
|
||||||
|
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||||
|
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||||
|
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||||
|
assert_raise Ash.Error.Invalid, fn ->
|
||||||
|
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
||||||
|
|> Ash.update!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list users returns only own user", %{user: user} do
|
||||||
|
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should only return the own user (scope :own filters)
|
||||||
|
assert length(users) == 1
|
||||||
|
assert hd(users).id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create user (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(user, actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
|
setup do
|
||||||
|
setup_user_with_own_access("read_only")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read own user record", %{user: user} do
|
||||||
|
{:ok, fetched_user} =
|
||||||
|
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
assert fetched_user.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update own email", %{user: user} do
|
||||||
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||||
|
user: user,
|
||||||
|
other_user: other_user
|
||||||
|
} do
|
||||||
|
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||||
|
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||||
|
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||||
|
assert_raise Ash.Error.Invalid, fn ->
|
||||||
|
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
||||||
|
|> Ash.update!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list users returns only own user", %{user: user} do
|
||||||
|
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should only return the own user (scope :own filters)
|
||||||
|
assert length(users) == 1
|
||||||
|
assert hd(users).id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create user (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(user, actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
|
setup do
|
||||||
|
setup_user_with_own_access("normal_user")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read own user record", %{user: user} do
|
||||||
|
{:ok, fetched_user} =
|
||||||
|
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
assert fetched_user.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update own email", %{user: user} do
|
||||||
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||||
|
user: user,
|
||||||
|
other_user: other_user
|
||||||
|
} do
|
||||||
|
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||||
|
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||||
|
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||||
|
assert_raise Ash.Error.Invalid, fn ->
|
||||||
|
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
||||||
|
|> Ash.update!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list users returns only own user", %{user: user} do
|
||||||
|
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should only return the own user (scope :own filters)
|
||||||
|
assert length(users) == 1
|
||||||
|
assert hd(users).id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create user (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(user, actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "admin permission set" do
|
||||||
|
setup do
|
||||||
|
user = create_user_with_permission_set("admin")
|
||||||
|
other_user = create_other_user()
|
||||||
|
|
||||||
|
# Reload user to ensure role is preloaded
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
|
||||||
|
%{user: user, other_user: other_user}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all users", %{user: user, other_user: other_user} do
|
||||||
|
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should return all users (scope :all)
|
||||||
|
user_ids = Enum.map(users, & &1.id)
|
||||||
|
assert user.id in user_ids
|
||||||
|
assert other_user.id in user_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read other users", %{user: user, other_user: other_user} do
|
||||||
|
{:ok, fetched_user} =
|
||||||
|
Ash.get(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
assert fetched_user.id == other_user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update other users", %{user: user, other_user: other_user} do
|
||||||
|
new_email = "adminupdated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
{:ok, updated_user} =
|
||||||
|
other_user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create user", %{user: user} do
|
||||||
|
{:ok, new_user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: user)
|
||||||
|
|
||||||
|
assert new_user.email
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy user", %{user: user, other_user: other_user} do
|
||||||
|
:ok = Ash.destroy(other_user, actor: user)
|
||||||
|
|
||||||
|
# Verify user is deleted
|
||||||
|
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "AshAuthentication bypass" do
|
||||||
|
test "register_with_password works without actor" do
|
||||||
|
# Registration should work without actor (AshAuthentication bypass)
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "register#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert user.email
|
||||||
|
end
|
||||||
|
|
||||||
|
test "register_with_rauthy works with OIDC user_info" do
|
||||||
|
# OIDC registration should work (AshAuthentication bypass)
|
||||||
|
user_info = %{
|
||||||
|
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||||
|
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||||
|
user_info: user_info,
|
||||||
|
oauth_tokens: oauth_tokens
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert user.email
|
||||||
|
assert user.oidc_id == user_info["sub"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sign_in_with_rauthy works with OIDC user_info" do
|
||||||
|
# First create a user with OIDC ID
|
||||||
|
user_info_create = %{
|
||||||
|
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||||
|
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||||
|
user_info: user_info_create,
|
||||||
|
oauth_tokens: oauth_tokens
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Now test sign_in_with_rauthy (should work via AshAuthentication bypass)
|
||||||
|
{:ok, signed_in_user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Query.for_read(:sign_in_with_rauthy, %{
|
||||||
|
user_info: user_info_create,
|
||||||
|
oauth_tokens: oauth_tokens
|
||||||
|
})
|
||||||
|
|> Ash.read_one()
|
||||||
|
|
||||||
|
assert signed_in_user.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
# NOTE: get_by_subject is tested implicitly via AshAuthentication's JWT flow.
|
||||||
|
# Direct testing via Ash.Query.for_read(:get_by_subject) doesn't properly
|
||||||
|
# simulate the AshAuthentication context and would require mocking JWT tokens.
|
||||||
|
# The AshAuthentication bypass policy ensures this action works correctly
|
||||||
|
# when called through the proper authentication flow (sign_in, token refresh, etc.).
|
||||||
|
# Integration tests that use actual JWT tokens cover this functionality.
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "test environment bypass (NoActor)" do
|
||||||
|
test "operations without actor are allowed in test environment" do
|
||||||
|
# In test environment, NoActor check should allow operations
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:create_user, %{
|
||||||
|
email: "noactor#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert user.email
|
||||||
|
|
||||||
|
# Read should also work
|
||||||
|
{:ok, fetched_user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts)
|
||||||
|
assert fetched_user.id == user.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
84
test/mv/authorization/actor_test.exs
Normal file
84
test/mv/authorization/actor_test.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule Mv.Authorization.ActorTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the Actor helper module.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Authorization.Actor
|
||||||
|
|
||||||
|
describe "ensure_loaded/1" do
|
||||||
|
test "returns nil when actor is nil" do
|
||||||
|
assert Actor.ensure_loaded(nil) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns actor as-is when role is already loaded" do
|
||||||
|
# Create user with role
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Load role
|
||||||
|
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should return as-is (no additional load)
|
||||||
|
result = Actor.ensure_loaded(user_with_role)
|
||||||
|
assert result.id == user.id
|
||||||
|
assert result.role != %Ash.NotLoaded{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads role when it's NotLoaded" do
|
||||||
|
# Create a role first
|
||||||
|
{:ok, role} =
|
||||||
|
Mv.Authorization.Role
|
||||||
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
name: "Test Role #{System.unique_integer([:positive])}",
|
||||||
|
description: "Test role",
|
||||||
|
permission_set_name: "own_data"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Create user with role
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Assign role to user
|
||||||
|
{:ok, user_with_role} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Fetch user again WITHOUT loading role (simulates "role not preloaded" scenario)
|
||||||
|
{:ok, user_without_role_loaded} =
|
||||||
|
Ash.get(Accounts.User, user_with_role.id, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# User has role as NotLoaded (relationship not preloaded)
|
||||||
|
assert match?(%Ash.NotLoaded{}, user_without_role_loaded.role)
|
||||||
|
|
||||||
|
# ensure_loaded should load it
|
||||||
|
result = Actor.ensure_loaded(user_without_role_loaded)
|
||||||
|
assert result.id == user.id
|
||||||
|
refute match?(%Ash.NotLoaded{}, result.role)
|
||||||
|
assert result.role.id == role.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns non-User actors as-is (no-op)" do
|
||||||
|
# Create a plain map (not Mv.Accounts.User)
|
||||||
|
other_actor = %{id: "fake", role: %Ash.NotLoaded{field: :role}}
|
||||||
|
|
||||||
|
# Should return as-is (pattern match doesn't apply to non-User)
|
||||||
|
result = Actor.ensure_loaded(other_actor)
|
||||||
|
assert result == other_actor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -76,8 +76,10 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
||||||
|
|
||||||
{:ok, result} = HasPermission.strict_check(own_data_user, authorizer, [])
|
{:ok, result} = HasPermission.strict_check(own_data_user, authorizer, [])
|
||||||
|
|
||||||
# Should return :unknown for :own scope (needs filter)
|
# Should return false for :own scope without record
|
||||||
assert result == :unknown
|
# This prevents bypassing expr-based filters in bypass policies
|
||||||
|
# The actual filtering is done via bypass policies with expr(id == ^actor(:id))
|
||||||
|
assert result == false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -104,14 +106,16 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "strict_check/3 - Scope :own" do
|
describe "strict_check/3 - Scope :own" do
|
||||||
test "actor with scope :own returns :unknown (needs filter)" do
|
test "actor with scope :own returns false (needs bypass policy with expr filter)" do
|
||||||
user = create_actor("user-123", "own_data")
|
user = create_actor("user-123", "own_data")
|
||||||
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||||
|
|
||||||
{:ok, result} = HasPermission.strict_check(user, authorizer, [])
|
{:ok, result} = HasPermission.strict_check(user, authorizer, [])
|
||||||
|
|
||||||
# Should return :unknown for :own scope (needs filter via auto_filter)
|
# Should return false for :own scope without record
|
||||||
assert result == :unknown
|
# This prevents bypassing expr-based filters in bypass policies
|
||||||
|
# The actual filtering is done via bypass policies with expr(id == ^actor(:id))
|
||||||
|
assert result == false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -270,4 +274,44 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "strict_check/3 - Role Loading Fallback" do
|
||||||
|
test "returns false if role is NotLoaded and cannot be loaded" do
|
||||||
|
# Create actor with NotLoaded role
|
||||||
|
# In real scenario, ensure_role_loaded would attempt to load via Ash.load
|
||||||
|
# For this test, we use a simple map to verify the pattern matching works
|
||||||
|
actor = %{
|
||||||
|
id: "user-123",
|
||||||
|
role: %Ash.NotLoaded{}
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||||
|
|
||||||
|
# Should handle NotLoaded pattern and return false
|
||||||
|
# (In real scenario, ensure_role_loaded would attempt to load, but for this test
|
||||||
|
# we just verify the pattern matching works correctly)
|
||||||
|
{:ok, result} = HasPermission.strict_check(actor, authorizer, [])
|
||||||
|
assert result == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false if role is nil" do
|
||||||
|
actor = %{
|
||||||
|
id: "user-123",
|
||||||
|
role: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||||
|
|
||||||
|
{:ok, result} = HasPermission.strict_check(actor, authorizer, [])
|
||||||
|
assert result == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "works correctly when role is already loaded" do
|
||||||
|
actor = create_actor("user-123", "admin")
|
||||||
|
authorizer = create_authorizer(Mv.Accounts.User, :read)
|
||||||
|
|
||||||
|
{:ok, result} = HasPermission.strict_check(actor, authorizer, [])
|
||||||
|
assert result == true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
52
test/mv/authorization/checks/no_actor_test.exs
Normal file
52
test/mv/authorization/checks/no_actor_test.exs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
defmodule Mv.Authorization.Checks.NoActorTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the NoActor Ash Policy Check.
|
||||||
|
|
||||||
|
This check allows actions without an actor ONLY in test environment.
|
||||||
|
In production/dev, all operations without an actor are denied.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Mv.Authorization.Checks.NoActor
|
||||||
|
|
||||||
|
describe "match?/3" do
|
||||||
|
test "returns true when actor is nil in test environment" do
|
||||||
|
# In test environment (config :allow_no_actor_bypass = true), NoActor allows operations
|
||||||
|
result = NoActor.match?(nil, %{}, [])
|
||||||
|
assert result == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false when actor is present" do
|
||||||
|
actor = %{id: "user-123"}
|
||||||
|
result = NoActor.match?(actor, %{}, [])
|
||||||
|
assert result == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses compile-time config (not runtime Mix.env)" do
|
||||||
|
# The @allow_no_actor_bypass is set via Application.compile_env at compile time
|
||||||
|
# In test.exs: config :mv, :allow_no_actor_bypass, true
|
||||||
|
# In prod/dev: not set (defaults to false)
|
||||||
|
# This ensures the check is release-safe (no runtime Mix.env dependency)
|
||||||
|
result = NoActor.match?(nil, %{}, [])
|
||||||
|
|
||||||
|
# In test environment (as compiled), should allow
|
||||||
|
assert result == true
|
||||||
|
|
||||||
|
# Note: We cannot test "production mode" here because the flag is compile-time.
|
||||||
|
# Production safety is guaranteed by:
|
||||||
|
# 1. Config only set in test.exs
|
||||||
|
# 2. Default is false (fail-closed)
|
||||||
|
# 3. No runtime environment checks
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "describe/1" do
|
||||||
|
test "returns description based on compile-time config" do
|
||||||
|
description = NoActor.describe([])
|
||||||
|
assert is_binary(description)
|
||||||
|
|
||||||
|
# In test environment (compiled with :allow_no_actor_bypass = true)
|
||||||
|
assert description =~ "test environment"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue