Skip to content

Commit 743b726

Browse files
authored
Move backend user authorization guards to the actual write events (#1503)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> RelationController's pivot handlers save every model returned by prepareModelsToSave(). 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 belongsToMany relation to Backend\Models\User, that no-op save fired User::beforeSave(), which throws for any operator lacking backend.manage_users. The relation became unusable for them. This is a regression against 1.2.9, surfaced by the user authorization guards added since. Fixes #1464
1 parent 6164251 commit 743b726

5 files changed

Lines changed: 571 additions & 15 deletions

File tree

modules/backend/behaviors/RelationController.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,14 +1583,18 @@ public function onRelationManagePivotUpdate()
15831583
{
15841584
$this->beforeAjax();
15851585

1586-
$foreignKeyName = $this->relationModel->getQualifiedKeyName();
15871586
$hydratedModel = $this->pivotWidget->model;
15881587
$saveData = $this->pivotWidget->getSaveData();
15891588

1590-
$modelsToSave = $this->prepareModelsToSave($hydratedModel, $saveData);
1591-
foreach ($modelsToSave as $modelToSave) {
1592-
$modelToSave->save(null, $this->pivotWidget->getSessionKey());
1593-
}
1589+
/*
1590+
* If any of the models fail to save, abort the whole update
1591+
*/
1592+
Db::transaction(function () use ($hydratedModel, $saveData) {
1593+
$modelsToSave = $this->prepareModelsToSave($hydratedModel, $saveData);
1594+
foreach ($modelsToSave as $modelToSave) {
1595+
$modelToSave->save(null, $this->pivotWidget->getSessionKey());
1596+
}
1597+
});
15941598

15951599
return ['#'.$this->relationGetId('view') => $this->relationRenderView()];
15961600
}

modules/backend/models/User.php

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,32 @@ class User extends UserBase
7878
*/
7979
public static $loginAttribute = 'login';
8080

81+
/**
82+
* @var array<string> Relations on this model that require `backend.manage_users`
83+
* to change on another user's record. Deliberately limited to the relations this
84+
* model owns: authorization semantics for plugin-added relations belong to the
85+
* plugin, which can append to this list or bind its own guard to the
86+
* `model.relation.*` events.
87+
*/
88+
public array $permissionGuardedRelations = ['groups', 'avatar', 'throttle'];
89+
90+
public function __construct(array $attributes = [])
91+
{
92+
parent::__construct($attributes);
93+
94+
// Guard relation writes at the point they actually happen: direct
95+
// attach()/detach(), deferred binding commits and queued relation
96+
// syncs all funnel through these relation events.
97+
$guard = function (string $relationName) {
98+
$this->authorizeRelationChange($relationName);
99+
};
100+
101+
$this->bindEvent('model.relation.beforeAttach', $guard);
102+
$this->bindEvent('model.relation.beforeDetach', $guard);
103+
$this->bindEvent('model.relation.beforeAdd', $guard);
104+
$this->bindEvent('model.relation.beforeRemove', $guard);
105+
}
106+
81107
/**
82108
* @return string Returns the user's full name.
83109
*/
@@ -156,9 +182,33 @@ public function canBeManagedByUser(?User $user = null): bool
156182
}
157183

158184
/**
159-
* Before save event — enforce authorization rules to prevent privilege escalation.
185+
* Before create event — enforce authorization rules to prevent privilege escalation.
160186
*/
161-
public function beforeSave()
187+
public function beforeCreate()
188+
{
189+
$this->authorizeChange();
190+
}
191+
192+
/**
193+
* Before update event — enforce authorization rules to prevent privilege escalation.
194+
*
195+
* Bound to update rather than save so that a save with nothing to write (e.g. a
196+
* pivot form submission re-saving an untouched related record) requires no
197+
* permission: the update event only fires when attributes have actually changed,
198+
* after purgeable attributes have been stripped. Relation writes (group
199+
* membership, avatar) are guarded separately by authorizeRelationChange().
200+
*/
201+
public function beforeUpdate()
202+
{
203+
$this->authorizeChange();
204+
}
205+
206+
/**
207+
* Enforce authorization rules for a change to this record's attributes.
208+
*
209+
* @throws AuthorizationException if the current user lacks permission
210+
*/
211+
protected function authorizeChange(): void
162212
{
163213
$actor = BackendAuth::getUser();
164214
if (!$actor) {
@@ -176,6 +226,35 @@ public function beforeSave()
176226
}
177227
}
178228

229+
/**
230+
* Enforce authorization rules for a change to one of this record's relations.
231+
*
232+
* Only the relations listed in $permissionGuardedRelations are guarded, so
233+
* plugin-added relations (e.g. records that reference a user) behave normally.
234+
*
235+
* Changes to your own record's guarded relations are allowed: groups do not
236+
* carry permissions out of the box, so they are not an escalation vector.
237+
*
238+
* @throws AuthorizationException if the current user lacks permission
239+
*/
240+
protected function authorizeRelationChange(string $relationName): void
241+
{
242+
if (!in_array($relationName, $this->permissionGuardedRelations)) {
243+
return;
244+
}
245+
246+
$actor = BackendAuth::getUser();
247+
if (!$actor) {
248+
return;
249+
}
250+
251+
$isCurrentUser = $this->exists && $actor->getKey() === $this->getKey();
252+
253+
if (!$isCurrentUser && !$this->canBeManagedByUser($actor)) {
254+
throw new AuthorizationException(Lang::get('backend::lang.user.cannot_manage_user'));
255+
}
256+
}
257+
179258
/**
180259
* Before delete event — enforce authorization rules.
181260
*/

0 commit comments

Comments
 (0)