Move backend user authorization guards to the actual write events - #1503
Merged
Conversation
RelationController's pivot handlers save every model returned by prepareModelsToSave(), which always queues the related model even when the submitted form contained nothing but pivot data. For a belongsToMany relation to Backend\Models\User that no-op save still fires User::beforeSave(), so operators without backend.manage_users were locked out of editing pivot data entirely - a regression against 1.2.9 introduced when those authorization guards were added. Skip models that already exist and have no changed attributes, committing their deferred bindings so relation widgets on the pivot form keep working (a deferred binding leaves its owning model clean, so skipping the save outright would silently discard it). Models the form actually changed are still saved, and still authorized. Also wraps onRelationManagePivotUpdate() in a transaction to match onRelationManagePivotCreate() - the pivot write previously committed even when a subsequent related-model save threw - and removes an unused variable. Fixes #1464 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This comment was marked as resolved.
This comment was marked as resolved.
Follow-up to review feedback on the previous commit. Skipping the save for a clean model and calling commitDeferred() directly bypassed the model's save pipeline, so an actor without backend.manage_users could commit a deferred `groups` binding on another user - group membership carries permissions, so that was an escalation path that did not exist before. A model with pending deferred bindings is now never skipped: committing those bindings changes the model's relations, so it goes through the normal save and is authorized like any other write. Only models that are genuinely inert - existing, clean, and with nothing deferred - are passed over, which is still the #1464 case. Adds the negative-authorization test that was missing (it fails against the previous commit), and uses firstOrFail() in the hydration helper for a clearer failure mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
|
@quendistudio this should fix #1464 |
The guard in Backend\Models\User::beforeSave() fired for every save, including saves with nothing to write. RelationController's pivot handlers save every model prepared from the form submission, so a pivot-only submission tripped the guard and locked operators without backend.manage_users out of editing pivot data (#1464). Rather than teaching the pivot handlers to predict whether a save will write anything (brittle: purgeable fields defeat isDirty(), and relation syncs queued by setSimpleValue() dirty nothing at all), the guard now sits on the events that only fire for actual writes: - beforeCreate()/beforeUpdate() guard attribute writes; the update event only fires when attributes actually changed, after purging - model.relation.beforeAttach/beforeDetach/beforeAdd/beforeRemove guard relation writes (group membership, avatar); direct attaches, deferred binding commits and queued relation syncs all funnel through them The pivot handlers save unconditionally again, and the update handler keeps the transaction wrap to match the create handler. Relation changes to other users' records now require the permission even via direct attach()/detach(), which previously bypassed the save-based guard entirely. Changes to your own record's relations remain allowed: backend groups carry no permissions out of the box. Fixes #1464 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
modules/backend/behaviors/RelationController.php (1)
1572-1576: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate save loop between the two pivot handlers.
Both
onRelationManagePivotCreate()andonRelationManagePivotUpdate()now contain the identicalprepareModelsToSave()+ per-modelsave(null, $sessionKey)loop. Extracting a small helper would remove the duplication this PR introduced.♻️ Proposed helper extraction
+ /** + * Prepares and saves each model for the pivot save data, under the pivot session key. + */ + protected function relationSavePivotPreparedModels($hydratedModel, array $saveData): void + { + $modelsToSave = $this->prepareModelsToSave($hydratedModel, $saveData); + foreach ($modelsToSave as $modelToSave) { + $modelToSave->save(null, $this->pivotWidget->getSessionKey()); + } + }Then in
onRelationManagePivotCreate():foreach ($hydratedModels as $hydratedModel) { - $modelsToSave = $this->prepareModelsToSave($hydratedModel, $saveData); - foreach ($modelsToSave as $modelToSave) { - $modelToSave->save(null, $this->pivotWidget->getSessionKey()); - } + $this->relationSavePivotPreparedModels($hydratedModel, $saveData); }And in
onRelationManagePivotUpdate():Db::transaction(function () use ($hydratedModel, $saveData) { - $modelsToSave = $this->prepareModelsToSave($hydratedModel, $saveData); - foreach ($modelsToSave as $modelToSave) { - $modelToSave->save(null, $this->pivotWidget->getSessionKey()); - } + $this->relationSavePivotPreparedModels($hydratedModel, $saveData); });Also applies to: 1592-1597
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@modules/backend/behaviors/RelationController.php` around lines 1572 - 1576, Extract the duplicated prepare-and-save sequence from onRelationManagePivotCreate() and onRelationManagePivotUpdate() into a shared private helper, preserving prepareModelsToSave(), the foreach iteration, and each model’s save(null, $this->pivotWidget->getSessionKey()) call. Replace both inline loops with calls to the helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@modules/backend/behaviors/RelationController.php`:
- Around line 1572-1576: Extract the duplicated prepare-and-save sequence from
onRelationManagePivotCreate() and onRelationManagePivotUpdate() into a shared
private helper, preserving prepareModelsToSave(), the foreach iteration, and
each model’s save(null, $this->pivotWidget->getSessionKey()) call. Replace both
inline loops with calls to the helper.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 51b57b4c-1aa8-4c2d-9e5b-090719a5a60e
📒 Files selected for processing (4)
modules/backend/behaviors/RelationController.phpmodules/backend/models/User.phpmodules/backend/tests/behaviors/RelationControllerPivotTest.phpmodules/backend/tests/models/UserAuthorizationTest.php
Guarding every model.relation.* event claimed authorization semantics over plugin-added relations too: a plugin writing a user-side relation directly (e.g. assigning another user to a ticket via $user->tickets()->add()) would newly require backend.manage_users even though the relation is the plugin's own domain — the same overreach that caused #1464. Only the relations core owns (groups, avatar, throttle) are guarded now, listed in the public $permissionGuardedRelations property so plugins can register their own sensitive relations or bind a stricter guard to the same events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1464
Problem
RelationController's pivot handlers save every model returned byprepareModelsToSave(). That list always includes the related model —FormModelSaver::setModelAttributes()pushes it before it even looks at$saveData— so a submission containing nothing but pivot data still saves the related record.For a
belongsToManyrelation toBackend\Models\User, that no-op save firedUser::beforeSave(), which throws for any operator lackingbackend.manage_users. The relation became unusable for them. This is a regression against 1.2.9, surfaced by the user authorization guards added since.Fix
An earlier revision of this PR taught the pivot handlers to skip models with nothing to write. Review showed that approach is inherently brittle — the save pipeline has several independent write channels (attribute updates, deferred binding commits, relation syncs queued by
setSimpleValue(), purgeable attributes that look dirty but aren't), and any skip heuristic must re-derive all of them. Two gaps were confirmed: relation-widget fields on the related model were silently dropped, and_-prefixed helper fields defeated theisDirty()check.This revision moves the guard instead, onto the events that only fire for actual writes:
beforeCreate()/beforeUpdate()guard attribute writes. Eloquent's update event only fires when attributes have genuinely changed — after purgeable attributes are stripped — so a save with nothing to write requires no permission.model.relation.beforeAttach/beforeDetach/beforeAdd/beforeRemoveguard relation writes. Directattach()/detach(), deferred binding commits (commitDeferredAfter→add()→attach()) and queuedsetSimpleValue()syncs (afterSave →sync()→attach()/detach()) all funnel through these events, so the guard fires at the moment the relation write happens rather than trying to predict it.The pivot handlers save every prepared model unconditionally again — restoring pre-1.2.10 behavior for plugin
afterSavehooks and relation-widget fields — andonRelationManagePivotUpdate()is wrapped inDb::transaction()to matchonRelationManagePivotCreate().Guard semantics
backend.manage_users; superuser targets need a superuser actor; CLI/queue contexts exempt).groups,avatar,throttle, listed in the public$permissionGuardedRelationsproperty. Changing them on another user's record requiresbackend.manage_users, now even via directattach()/detach()— those paths previously bypassed the save-based guard entirely.$user->tickets()->add()) keeps working for unprivileged operators, since that relation is the plugin's domain. Plugins with genuinely sensitive relations can append to$permissionGuardedRelationsor bind their own guard to the samemodel.relation.*events. (Relations that merely reference users from the other side —$ticket->users()->attach()— never touched the guard, since the events fire on the parent model.)getMergedPermissions()merges only role + user permissions), so group membership is not an escalation vector.Tests
RelationControllerPivotTest(10 cases) exercises the production pivot save sequence end-to-end: pivot-only saves with and without the permission, related-field edits (applied and refused), relation-widget fields via thesetSimpleValue()path (applied and refused), and deferred binding commits (applied and refused).UserAuthorizationTestgrows from 31 to 39 cases: no-op saves need no permission, creates are guarded, group attach/detach/sync are guarded for other users' records, self group changes stay allowed, and a plugin-style relation added to the user model is not guarded.Negative control: with the
model.relation.*bindings commented out, all six unauthorized-relation-change tests fail — the coverage bites.Full core suite passes (backend: 169 tests / 371 assertions; the only failures elsewhere are 3 pre-existing environmental failures in
cms/ControllerTestcaused by a local asset-URL config, present on the unmodified branch too). phpcs clean on all changed files.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Tests