Short description of the issue
Roles::loaded() grants page-view to every role in memory and never stores it, so in a stock install field_permissions is empty. That grant does not survive Page::uncache(): reloading the field finds nothing in the database, and Roles::loaded() does not run again because it only fires when the page is fetched through $roles, not when a field is lazily reloaded.
Since $pages->save() calls uncacheAll(), any save leaves a non-superuser without page-view for the rest of the request. This breaks the core password reset, which returns a 500 and names the wrong module.
Expected behavior
$user->hasPermission('page-view') keeps returning true after an unrelated $pages->save() in the same request.
Actual behavior
It returns false, and the role's permissions PageArray is empty.
Steps to reproduce the issue
On a clean 3.0.255 install with the blank profile and no third-party modules:
$guest = $users->get('guest');
$guest->hasPermission('page-view'); // true
$guest->roles->first()->permissions->count(); // 1
$pages->uncacheAll(); // or any $pages->save()
$guest->hasPermission('page-view'); // false
$guest->roles->first()->permissions->count(); // 0
Add a row to field_permissions for that role and permission, and the problem disappears: the value then reloads from the database and both calls keep returning true.
Why the grant is runtime-only
wire/core/Roles.php:110-119:
protected function loaded(Page $page) {
$hasPageView = false;
foreach($page->permissions as $permission) {
if($permission->name === 'page-view') $hasPageView = true;
if($hasPageView) break;
}
if(!$hasPageView) {
$pageView = $this->wire()->permissions->get('page-view');
$page->permissions->add($pageView);
}
}
field_permissions is empty in site-blank/install/install.sql, so on a fresh install every role gets page-view this way and nothing else.
wire/core/Page.php:3921-3925 then discards it:
foreach($fieldgroup as $field) {
$value = parent::get($field->name);
if(!is_object($value)) continue;
parent::set($field->name, null);
unset($this->wakeupNameQueue[$field->name]);
}
wire/core/PagesLoaderCache.php:203-208 preserves the current user's own page but not the role pages it references:
foreach($this->pageIdCache as $id => $page) {
if($id == $user->id || ($language && $language->id == $id)) continue;
if($page->numChildren) continue;
if(empty($options['shallow'])) $page->uncache();
How it surfaces in the core password reset
- A logged-out visitor completes a reset.
ProcessForgotPassword.module:624 saves the new password, so Pages::save() calls uncacheAll().
- Line 635 redirects.
Session::redirect() fetches ProcessPageView at Session.php:1387, because wire('process') is ProcessLogin rather than ProcessPageView.
ModulesLoader::hasPermission() calls $user->hasPermission('page-view'), which now returns false, and throws.
wire/core/admin.php:183 catches that and calls $modules->get("ProcessLogin") at line 192 to render the login form. That call fails for the same reason and is uncaught.
The visitor gets a 500. The password has already been changed by then, which makes the failure look unrelated to the save.
The reported exception is the second one, so it points at the wrong module:
WirePermissionException: You do not have permission to execute this module - ProcessLogin (page=23)
in wire/core/Modules.php:599
Logging the first one through $config->statusFiles('failed', ...) shows what actually happened:
[Permission error from ProcessLogin] WirePermissionException:
You do not have permission to execute this module - ProcessPageView (page=23)
user=guest (id=40) roles=guest page-view=no
#0 wire/core/Modules.php(453): Modules->getModule()
#1 wire/core/Session.php(1387): Modules->get()
#2 wire/core/Wire.php(416): Session->___redirect()
#5 ProcessForgotPassword.module(730): Wire->__call()
#6 ProcessForgotPassword.module(635): ProcessForgotPassword->deleteResetAndRedirect()
#7 ProcessForgotPassword.module(470): ProcessForgotPassword->step4_completeReset()
Note roles=guest and page-view=no at the same time: the role is still attached, only its permissions are gone.
This stays invisible in most situations because three things have to coincide in one request: the user is not a superuser, a page is saved, and a permission check follows. A password reset by a logged-out visitor is one of the few core flows that does all three.
Suggested fix
Either re-apply the Roles::loaded() grant when a Role's permissions field is reloaded, or preserve the current user's roles in uncacheAll() alongside the user page itself.
Two workarounds, both verified:
Store the grant, so there is something to reload:
foreach($roles as $role) {
$role->of(false);
$role->trackChange('permissions'); // the value is unchanged in memory, so mark it
$role->save();
}
Note that saving the role from the admin does not do this: the form shows page-view already checked, so no change is detected and nothing is written.
Or re-fetch the roles after each save:
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$user = $event->wire()->user;
if(!$user || $user->isSuperuser()) return;
$roles = $event->wire()->roles;
$fresh = $event->wire(new PageArray());
foreach($user->getUnformatted('roles') as $role) {
$reloaded = $roles->get((int) $role->id);
if($reloaded && $reloaded->id) $fresh->add($reloaded);
}
if($fresh->count()) $user->setQuietly('roles', $fresh);
});
Setup/Environment
- ProcessWire 3.0.255.
PagesLoaderCache::uncacheAll() is identical in 3.0.259, so this is not fixed there.
- Reproduced on a clean 3.0.255 install with the
site-blank profile and a fresh database from install.sql, and on an existing site.
- PHP 8.3.33 and 8.5.9, reproduced on both.
- MariaDB 10.4.24 with InnoDB and MariaDB 5.5.68 with MyISAM, reproduced on both.
- No third-party modules installed.
Short description of the issue
Roles::loaded()grantspage-viewto every role in memory and never stores it, so in a stock installfield_permissionsis empty. That grant does not survivePage::uncache(): reloading the field finds nothing in the database, andRoles::loaded()does not run again because it only fires when the page is fetched through$roles, not when a field is lazily reloaded.Since
$pages->save()callsuncacheAll(), any save leaves a non-superuser withoutpage-viewfor the rest of the request. This breaks the core password reset, which returns a 500 and names the wrong module.Expected behavior
$user->hasPermission('page-view')keeps returning true after an unrelated$pages->save()in the same request.Actual behavior
It returns false, and the role's
permissionsPageArray is empty.Steps to reproduce the issue
On a clean 3.0.255 install with the blank profile and no third-party modules:
Add a row to
field_permissionsfor that role and permission, and the problem disappears: the value then reloads from the database and both calls keep returning true.Why the grant is runtime-only
wire/core/Roles.php:110-119:field_permissionsis empty insite-blank/install/install.sql, so on a fresh install every role getspage-viewthis way and nothing else.wire/core/Page.php:3921-3925then discards it:wire/core/PagesLoaderCache.php:203-208preserves the current user's own page but not the role pages it references:How it surfaces in the core password reset
ProcessForgotPassword.module:624saves the new password, soPages::save()callsuncacheAll().Session::redirect()fetchesProcessPageViewatSession.php:1387, becausewire('process')isProcessLoginrather thanProcessPageView.ModulesLoader::hasPermission()calls$user->hasPermission('page-view'), which now returns false, and throws.wire/core/admin.php:183catches that and calls$modules->get("ProcessLogin")at line 192 to render the login form. That call fails for the same reason and is uncaught.The visitor gets a 500. The password has already been changed by then, which makes the failure look unrelated to the save.
The reported exception is the second one, so it points at the wrong module:
Logging the first one through
$config->statusFiles('failed', ...)shows what actually happened:Note
roles=guestandpage-view=noat the same time: the role is still attached, only its permissions are gone.This stays invisible in most situations because three things have to coincide in one request: the user is not a superuser, a page is saved, and a permission check follows. A password reset by a logged-out visitor is one of the few core flows that does all three.
Suggested fix
Either re-apply the
Roles::loaded()grant when a Role'spermissionsfield is reloaded, or preserve the current user's roles inuncacheAll()alongside the user page itself.Two workarounds, both verified:
Store the grant, so there is something to reload:
Note that saving the role from the admin does not do this: the form shows
page-viewalready checked, so no change is detected and nothing is written.Or re-fetch the roles after each save:
Setup/Environment
PagesLoaderCache::uncacheAll()is identical in 3.0.259, so this is not fixed there.site-blankprofile and a fresh database frominstall.sql, and on an existing site.