Update documentation for User credentials strategy
All checks were successful
continuous-integration/drone/push Build is passing

Clarify that User.update :own is handled by HasPermission.
Fix file path references from lib/mv/accounts to lib/accounts.
This commit is contained in:
Moritz 2026-01-22 21:36:22 +01:00
parent d97f6f4004
commit 811a276d92
3 changed files with 69 additions and 11 deletions

View file

@ -81,6 +81,17 @@ end
| **CREATE** | ✅ 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 | | **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 ## Why `scope :own` Is NOT Redundant

View file

@ -930,7 +930,7 @@ This ensures consistent behavior and predictable authorization logic throughout
### User Resource Policies ### User Resource Policies
**Location:** `lib/mv/accounts/user.ex` **Location:** `lib/accounts/user.ex`
**Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own). **Pattern:** Bypass for READ (list queries), HasPermission for UPDATE (with scope :own).
@ -1744,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
@ -1762,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

View file

@ -539,7 +539,7 @@ Following the same pattern as Member resource:
**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 AshAuthentication bypass (registration/login without actor) 3. ✅ Add AshAuthentication bypass (registration/login without actor)
4. ✅ Add NoActor bypass (test environment only) 4. ✅ Add NoActor bypass (test environment only)