Problem/Motivation
#2340507: Make the new AccessResult API and implementation even better simplified the AccessManager, but crucially dropped an early break for an access check that will ultimately lead to the route access being denied:
// Stop as soon as the first non-allowed check is encountered.
if (!$result->isAllowed()) {
break;
}
Now it just loops over all access checks and calls AccessResult::andIf() on them.
This has serious repercussions that can lead to performance issues: Before you were able to put the cheapest check first and the expensive ones later.
E.g.: A _permission check first and then a custom check that needs to load a bunch of entities. If you do not have the permission, it makes no sense to add several entities and cache contexts extra to the page when we know you won't get access to it.
We should reintroduce the early break and test that it works as desired. Then we can optimize routes by making sure the cheapest checks go first.
This does not block the planned review in #2991698: Why does Route access checking differ from Entity access checking?, but it would improve performance until that one lands.
Steps to reproduce
Add a permission check to a route and a custom one second. Put a breakpoint in said access check and see that it's still called even if you do not have the permission.
Proposed resolution
Only run the access checks we need to run.
Remaining tasks
User interface changes
Introduced terminology
API changes
Data model changes
Release notes snippet
Issue fork drupal-3626740
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 3626740-11-x
changes, plain diff MR !17342
- 3626740-accessmanager-should-once
changes, plain diff MR !17290
Comments
Comment #3
kristiaanvandeneyndeDeliberately not adding a test yet to see what the test suite has to say with this change.
Comment #4
graber commentedChecked the failed tests, here's a summary:
Drupal\Tests\ckeditor5\Functional\MediaEntityMetadataApiTest::testApi(functional)Reason: The request got a 403 response, but the test expected 400 (MediaEntityMetadataApiTest.php:272).
Needs investigating a bit more but I'd just change test assertion (there's already a comment saying 403 is expected).
Drupal\Tests\demo_umami\FunctionalJavascript\OpenTelemetryPerformanceTest::testUmamiPerformance(performance)Reason: The performance counts didn't match. The page did slightly less work than expected: QueryCount was 261 (expected 264), CacheGetCount 258 (259), menu bin cache gets 21 (22), and CacheSetCount 266 (267).
That's good, counts can be updated if there are no remaining issues.
Drupal\Tests\media_library\FunctionalJavascript\EntityReferenceWidgetTest::testWidget(JS)Reason: The text "Bear" was on the page when the test expected it to be absent (EntityReferenceWidgetTest.php:250).
Needs investigating, looks like a random failure though.
Comment #5
kristiaanvandeneyndeOnly remaining failure is MediaEntityMetadataApiTest, which uses the route
ckeditor5.media_entity_metadata, which has:Now, CKEditor5MediaController::access() used to throw a BadRequestException as follows:
So this piece of test code expected that, but the comment above was misleading.
But that no longer happens because
entity_access: editor.usefails before the access check, leading to the other one never failing on the absence of the UUID. Whether that's desired is up for debate, but I'd say it's better to 403 and not run extra access checks than to run ones that don't matter and show their 400 status codes.The fix is to always assert 403, as that's what's being tested here according to the comment.
See FilterFormatAccessControlHandler.php:
In the test the anonymous user does not have the permission and the filter is not the fallback, so 403 is returned and the other checks no longer run with the MR here. The test already checks for a missing or malformed UUID returning 400 when the entity access check does go through, so there is nothing else to be done here.
Comment #6
kristiaanvandeneyndeDeleted because it was a red herring
Comment #7
kristiaanvandeneyndeThe performance gain comes from this route:
That second check no longer runs in
OpenTelemetryPerformanceTest::doTestNodePageAdministrator()because it does not assign the "administer display modes" permission, implicitly proving that this MR works and improves performance.Comment #8
graber commentedI think that looks good!
Now a completely different FunctionalJS test fails, also doesn't look related so giving it RTBC, we don't want to keep rerunning the suite until we get green ;)
Comment #11
catchVery nice find.
Committed/pushed to main and 12.0.x, thanks!
Will need a backport MR for 11.x
Comment #13
kristiaanvandeneynde11.x MR created
Comment #14
catchPHPStan is unhappy.
Comment #15
kristiaanvandeneyndeI didn't touch that code at all o.O Even on a rerun it goes red.
Comment #16
kristiaanvandeneyndeAlso goes red in the daily 11.x tests, so unrelated to this issue.
https://git.drupalcode.org/project/drupal/-/pipelines/989488
Comment #17
catchOh that's my fault, just reverted #3626548: Fix RunInSeparateProcesses typos
Comment #18
godotislate11.x MR diff looks the same as main commit diff.
Comment #19
berdirAre we certain that an access denied is never cached in a place that would result in incorrectly not showing links when you do have access to them? For example links in a menu that require additional access?
Generally, for cacheability we always preach that cache contexts need to be always added, whether or not the check is successful and not just inside an if condition.
I'm actually not sure I can provide an example and it _might_ be ok, as it's only negative result and anyone woul would have access would cause it to re-evaluate anyway. But I wanted to ask.
Comment #20
kristiaanvandeneyndeYeah it's fine. I tested it and it works out well.
The idea is that you do not need cacheable metadata of checks that will never happen, and that's exactly what we're doing here. If a check happens and fails, then the cacheable metadata needs to carry onto the next check, which also happens in the MR here.
Comment #22
catchYes we need cacheable metadata for all checks that happen, but not ones that don't - individual access checks that check multiple things can bail out early for the same reason, as long as the cumulative cacheable metadata up until that point is OK.
It's possible that a contrib module or custom access callback could fail to add cacheable metadata for an early-failing check which it adds later, but that would work only by accident at best.
Committed/pushed the backport to 11.x.
This was a really, really nice find, can't believe it was broken all the way since prior to 8.0.0