[3.0] Hand back the permission sets that came from the cache - #9629
Conversation
With caching on, guests and members lost every permission they had. A guest
asking for the stats page got the log-in prompt, the search box vanished from
every page, and boards and topics rendered without the things a permission
gates. Admins were unaffected, which is why this survived: their permissions
are filled in before the cache is consulted and their group is dropped from
the lookup, so anyone testing while logged in as an admin sees nothing wrong.
GroupPermissionSet::load() collects the instances it constructs and returns
them. loadGlobalPermissionData() and loadBoardPermissionData() then either
fill those same instances in from the database, which the caller sees because
it is holding them, or replace the entries in self::$loaded outright with the
sets read from the cache, which the caller does not see because it is still
holding the empty ones it was given. So a cache hit returned sets with no
permissions in them while the correct ones sat in self::$loaded.
Reading the return value back out of self::$loaded once the data is in covers
both routes. UserPermissionSet::load() is the caller that builds User::$me's
permissions, so this is the whole of what a member is allowed to do.
In the same function, the test for whether every profile was found in the
cache is written
if ($hits = \count($profiles)) {
which assigns instead of comparing and is therefore always true, dropping the
group from the list of those still to look up whether or not anything was
found for it. Board permissions were never queried for a group once the cache
had been consulted at level 2 or above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
|
Diagnosis is correct, but the proposed fix is problematic. I will create an alternative fix shortly. |
Co-authored-by: Jon Stovell <jonstovell@gmail.com>
The instance the constructor builds is appended to the list, and the loop below appends whatever ended up in self::$loaded for the same group, so a group that had to be looked up came back twice. On a cache hit the two are different objects: the first is the empty instance the cached set replaced. Callers that read the list with current() take the first of the pair, and two of them mutate every set the list hands them and save it, so the pair turns one write into two against objects that do not agree. The constructor registers each instance in self::$loaded itself, so dropping the append leaves exactly one set per profile and group, whichever the load ended up with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
| // ended up in self::$loaded. | ||
| foreach ($profiles as $profile) { | ||
| foreach ($query_groups as $group) { | ||
| if (isset(self::$loaded[$profile][$group])) { |
There was a problem hiding this comment.
Applied, and you were right that the reset was unnecessary — I had convinced myself your version would leave the bug in place and it does not. Tested it: the stats page stays at its full size across a cache miss and two hits, where before it collapsed to a login page on the second request.
One thing it does change though. The construction loop above already appends the instance it builds, so appending again from self::$loaded returns a group twice once it has been looked up — and on a cache hit those two are different objects, the first being the empty one the cached set replaced:
reviewer's version group 0: load() returned 2 set(s)
with the append gone group 0: load() returned 1 set(s)
current() happens to pick a correct one, so nothing visibly breaks. But Permissions.php:1324 and PermissionProfile.php:115 both mutate every set the list hands them and call save(), so the pair doubles the writes against two objects that disagree, and only the ordering decides which one lands last.
Since the constructor already registers the instance in self::$loaded, dropping the append is enough — no reset, your loop, and one set per group. That is 7513031.
Description
With caching on, guests and members lose every permission they have. A guest asking for the stats page gets the log-in prompt, the search box disappears from every page, and boards and topics render without the things a permission gates.
Admins are unaffected, which is why this survived: their permissions are filled in before the cache is consulted and their group is then dropped from the lookup, so anyone testing while logged in as an admin sees nothing wrong.
GroupPermissionSet::load()collects the instances it constructs and returns them.loadGlobalPermissionData()andloadBoardPermissionData()then do one of two things. On a miss they fill those same instances in from the database, which the caller sees, because it is holding them. On a hit they replace the entries inself::$loadedoutright with the sets read from the cache, which the caller does not see, because it is still holding the empty ones it was handed. So a cache hit returned sets with no permissions in them while the correct ones sat inself::$loaded.Reading the return value back out of
self::$loadedonce the data is in covers both routes.UserPermissionSet::load()is the caller that buildsUser::$me's permissions, so this is the whole of what a member is allowed to do.In the same function, the test for whether every profile was found in the cache is written
which assigns instead of comparing and is therefore always true, dropping the group from the list of those still to look up whether or not anything was found for it. Board permissions were never queried for a group once the cache had been consulted at level 2 or above.
How this was checked
Against a running forum at
$cache_enable = 3. The stats page is 26,932 bytes on the first request and 8,234 on the second — not a slow page, a log-in page. Reproduced on an unmodified checkout to be sure it was not something I had introduced, then confirmed fixed: the same seven pages now render byte-identical on a cache hit, a cache miss, and with caching switched off entirely.Separately, comparing every group's resolved permissions with a cold cache against a warm one: before this change they differ, after it all 26 groups match, including the admin and global moderator groups.
Notes for review
No regression test.
GroupPermissionSetneedsDb::$db, and the unit suite has no database connection, so this is out of its reach. Per AGENTS.md I am saying so rather than faking a database to force it under test.Worth a second opinion on the blast radius:
Sources/Actions/Admin/Permissions.phpreadscurrent(GroupPermissionSet::load($profile, $group))->permissionsto render the permission editor. With caching on that form would show a group as having nothing, and saving it looks like a plausible way to wipe real permissions. I have not tried to confirm that, and did not want to find out on a forum I could not restore.Sources/Permissions/GroupPermissionSet.phpis also touched by #9302, which addsPermission::exists()filtering in the two data loaders. Different lines and a different concern, so the two should not overlap.Issues References (Fixes|Related|Closes)
🤖 Generated with Claude Code