Skip to content

Commit 3ee9e6b

Browse files
LukeTowersclaude
andcommitted
Authorize deferred bindings on otherwise-clean models
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>
1 parent bb35547 commit 3ee9e6b

2 files changed

Lines changed: 52 additions & 7 deletions

File tree

modules/backend/behaviors/RelationController.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,26 +1682,47 @@ public function relationExtendRefreshResults($field)
16821682
/**
16831683
* Saves the models prepared from a pivot form submission.
16841684
*
1685-
* Models that already exist and have no changed attributes are skipped. `prepareModelsToSave()`
1685+
* Models that already exist and have nothing pending are skipped. `prepareModelsToSave()`
16861686
* always queues the related model, so a submission containing nothing but pivot data would
16871687
* otherwise trigger that model's save events - including authorization guards such as
1688-
* `Backend\Models\User::beforeSave()`. Their deferred bindings are still committed so that
1689-
* relation widgets on the pivot form continue to work.
1688+
* `Backend\Models\User::beforeSave()`.
1689+
*
1690+
* A model with deferred bindings is never skipped, even when its own attributes are clean:
1691+
* committing those bindings is a change to the model's relations, so it must go through the
1692+
* normal save pipeline and be authorized like any other.
16901693
*/
16911694
protected function relationSavePivotModels(array $modelsToSave): void
16921695
{
16931696
$sessionKey = $this->pivotWidget->getSessionKey();
16941697

16951698
foreach ($modelsToSave as $modelToSave) {
1696-
if ($modelToSave->exists && !$modelToSave->isDirty()) {
1697-
$modelToSave->commitDeferred($sessionKey);
1699+
if (
1700+
$modelToSave->exists
1701+
&& !$modelToSave->isDirty()
1702+
&& !$this->relationHasDeferredBindings($modelToSave, $sessionKey)
1703+
) {
16981704
continue;
16991705
}
17001706

17011707
$modelToSave->save(null, $sessionKey);
17021708
}
17031709
}
17041710

1711+
/**
1712+
* Returns whether the model has any deferred bindings pending for the given session key.
1713+
*/
1714+
protected function relationHasDeferredBindings($model, string $sessionKey): bool
1715+
{
1716+
$binding = new DeferredBinding;
1717+
1718+
$binding->setConnection($model->getConnectionName());
1719+
1720+
return $binding
1721+
->where('master_type', get_class($model))
1722+
->where('session_key', $sessionKey)
1723+
->exists();
1724+
}
1725+
17051726
/**
17061727
* Returns the existing record IDs for the relation.
17071728
*/

modules/backend/tests/behaviors/RelationControllerPivotTest.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ protected function getHydratedRelatedUser(): User
8484
{
8585
return $this->fixture->users()
8686
->where('backend_users.id', $this->targetUser->id)
87-
->first();
87+
->firstOrFail();
8888
}
8989

9090
/**
@@ -202,7 +202,7 @@ public function testRelatedModelSaveIsStillAuthorizedWhenItsOwnFieldsAreEdited()
202202
* Skipping the save must not drop pending relation work. A deferred binding leaves the
203203
* owning model clean, so it would be lost if the model were simply passed over.
204204
*/
205-
public function testDeferredBindingsAreCommittedForSkippedModels(): void
205+
public function testDeferredBindingsAreCommittedForOtherwiseCleanModels(): void
206206
{
207207
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', true));
208208

@@ -223,4 +223,28 @@ public function testDeferredBindingsAreCommittedForSkippedModels(): void
223223
$this->assertEquals(1, $hydrated->groups()->count(), 'The deferred binding was committed');
224224
$this->assertEquals(1, $this->getPivotValue());
225225
}
226+
227+
/**
228+
* Skipping the save must not become an authorization bypass. A deferred binding leaves the
229+
* owning model clean, so a naive skip would commit relation changes - such as adding a user
230+
* to a group, which carries permissions - without ever consulting `User::beforeSave()`.
231+
*/
232+
public function testDeferredBindingsAreStillAuthorizedForOtherwiseCleanModels(): void
233+
{
234+
$this->actingAs((new UserFixture)->withPermission('backend.manage_users', false));
235+
236+
$hydrated = $this->getHydratedRelatedUser();
237+
238+
$group = UserGroup::create([
239+
'name' => 'Test Group',
240+
'code' => 'test-group',
241+
]);
242+
$hydrated->groups()->add($group, 'pivottestsessionkey');
243+
244+
$this->assertFalse($hydrated->isDirty(), 'The deferred binding leaves the user clean');
245+
246+
$this->expectException(AuthorizationException::class);
247+
248+
$this->savePivotForm($hydrated, ['pivot' => ['is_default' => true]]);
249+
}
226250
}

0 commit comments

Comments
 (0)