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

Command icon 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:

Comments

kristiaanvandeneynde created an issue. See original summary.

kristiaanvandeneynde’s picture

Status: Active » Needs review

Deliberately not adding a test yet to see what the test suite has to say with this change.

graber’s picture

Status: Needs review » Needs work

Checked 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.

kristiaanvandeneynde’s picture

Status: Needs work » Needs review

Only remaining failure is MediaEntityMetadataApiTest, which uses the route ckeditor5.media_entity_metadata, which has:

name: ckeditor5.media_entity_metadata
path: '/ckeditor5/{editor}/media-entity-metadata'
defaults:
  _controller: '\Drupal\ckeditor5\Controller\CKEditor5MediaController::mediaEntityMetadata'
requirements:
  _entity_access: editor.use
  _custom_access: '\Drupal\ckeditor5\Controller\CKEditor5MediaController::access'
  _csrf_token: 'TRUE'
options:
  parameters:
    editor:
      type: 'entity:editor'
      converter: paramconverter.entity
  _access_checks:
    - access_check.entity
    - access_check.custom
    - access_check.csrf

Now, CKEditor5MediaController::access() used to throw a BadRequestException as follows:

    $uuid = $request->query->get('uuid');
    if (!$uuid || !Uuid::isValid($uuid)) {
      throw new BadRequestHttpException();
    }

So this piece of test code expected that, but the comment above was misleading.

    // Ensure that users that don't have access to the filter format receive
    // either 404 or 403.
    $this->drupalLogout();
    $token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
    $this->drupalGet($path, ['token' => $token]);
    $this->assertSession()->statusCodeEquals(400);

But that no longer happens because entity_access: editor.use fails 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:

    // All users are allowed to use the fallback filter.
    if ($operation == 'use') {
      if ($filter_format->isFallbackFormat()) {
        return AccessResult::allowed();
      }
      else {
        return AccessResult::allowedIfHasPermission($account, $filter_format->getPermissionName());
      }
    }

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.

kristiaanvandeneynde’s picture

Deleted because it was a red herring

kristiaanvandeneynde’s picture

The performance gain comes from this route:

name: field_ui.display_mode
path: /admin/structure/display-modes
defaults:
  _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage'
  _title: 'Display modes'
requirements:
  _permission: 'administer display modes'
  _access_admin_menu_block_page: 'TRUE'
options:
  _admin_route: true
  _access_checks:
    - access_check.permission
    - access_check.admin_menu_block_page

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.

graber’s picture

Status: Needs review » Reviewed & tested by the community

I 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 ;)

  • catch committed 358d2c72 on 12.0.x
    fix: #3626740 AccessManager should (once again) bail out early to save...

  • catch committed 528d4178 on main
    fix: #3626740 AccessManager should (once again) bail out early to save...
catch’s picture

Status: Reviewed & tested by the community » Patch (to be ported)

Very nice find.

Committed/pushed to main and 12.0.x, thanks!

Will need a backport MR for 11.x

kristiaanvandeneynde’s picture

11.x MR created

catch’s picture

Status: Patch (to be ported) » Needs work

PHPStan is unhappy.

kristiaanvandeneynde’s picture

I didn't touch that code at all o.O Even on a rerun it goes red.

kristiaanvandeneynde’s picture

Status: Needs work » Patch (to be ported)

Also goes red in the daily 11.x tests, so unrelated to this issue.

https://git.drupalcode.org/project/drupal/-/pipelines/989488

catch’s picture

Status: Patch (to be ported) » Needs review

Oh that's my fault, just reverted #3626548: Fix RunInSeparateProcesses typos

godotislate’s picture

Status: Needs review » Reviewed & tested by the community

11.x MR diff looks the same as main commit diff.

berdir’s picture

Are 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.

kristiaanvandeneynde’s picture

Yeah 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.

  • catch committed e6cac3bb on 11.x
    fix: #3626740 AccessManager should (once again) bail out early to save...
catch’s picture

Version: main » 11.x-dev
Status: Reviewed & tested by the community » Fixed

Yes 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

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

  • longwave committed c4ab1d2d on 11.5.x authored by catch
    fix: #3626740 AccessManager should (once again) bail out early to save...