Problem/Motivation

If the current user does not have the permission to edit/delete any node of a given type, node_node_access() always adds the user cache context for the update/delete operation as part of checking the edit/delete own permissions.

There are however several cases where that's actually unnecessary, for example for users without the edit/delete own permission.

It could still end up being merged in if a user has the permission, but you can at least avoid it if no user has that permission.

This isn't a huge deal by default as it "just" results in the local tasks block being given the user cache context, but if you combine it with #2972308: Allow users to translate content they can edit, then through content_translation_language_fallback_candidates_entity_view_alter(), you can end up with the user cache context on the main content, disabling dynamic page cache in the process.

Proposed resolution

Check the permission first, do not add the user cache context if the user doesn't have the permission.

That will also result in fewer cached local tasks blocks, especially on sites with many users without edit/delete any permissions.

Remaining tasks

User interface changes

The local task block on node pages does no longer get placeholdered, which results in less changes/jumps of content.

API changes

Data model changes

Release notes snippet

CommentFileSizeAuthor
#35 3016781-35-interdiff.txt831 bytesBerdir
#35 3016781-35.patch8.66 KBBerdir
#32 3016781-32.patch7.85 KBcatch
#31 interdiff-3016781-30.txt2.37 KBcatch
#31 3016781-31.patch8.32 KBcatch
#18 3016781-10-18-interdiff.txt6.21 KBarpad.rozsa
#18 3016781-node-node-access-cache-contexts-18.patch7.78 KBarpad.rozsa
#18 3016781-node-node-access-cache-contexts-18-test-only.patch2.3 KBarpad.rozsa
#10 3016781-6-10-interdiff.txt838 bytesarpad.rozsa
#10 3016781-node-node-access-cache-contexts-10.patch7.06 KBarpad.rozsa
#10 3016781-node-node-access-cache-contexts-10-test-only.patch1.9 KBarpad.rozsa
#7 3016781-node-node-access-cache-contexts-6.patch7.16 KBarpad.rozsa
#7 3016781-node-node-access-cache-contexts-6-test-only.patch1.99 KBarpad.rozsa
#6 3016781-node-node-access-cache-contexts-5-test-only.patch1.99 KBarpad.rozsa
#5 3016781-node-node-access-cache-contexts-5-test-only.patch1.99 KBarpad.rozsa
#5 3016781-node-node-access-cache-contexts-5.patch6.95 KBarpad.rozsa
#5 3016781-2-5-interdiff.txt4.68 KBarpad.rozsa
#2 3016781-node-node-access-cache-contexts-2.patch2.27 KBBerdir
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Berdir created an issue. See original summary.

Berdir’s picture

Status: Active » Needs review
Issue tags: +Needs tests
FileSize
2.27 KB

There's so much broken about this function...

This overlaps/conflicts with #2348203: hook_node_access() no longer fires for the 'create' operation as well as #2883553: Obsolete argument for hasPermission in node_node_access().

By converting to an elseif, we also need to add break and add the neutral case outside of the default.

And in the end, this function should simply not exist at all, node should not implement the hook for its own entity type, this logic belongs in \Drupal\node\NodeAccessControlHandler::checkAccess(). That would probably be too much for a single issue, though.

Berdir’s picture

For tests, we could maybe extend \Drupal\Tests\node\Kernel\NodeAccessTest to also check for the correct cacheablity metadata.

Status: Needs review » Needs work

The last submitted patch, 2: 3016781-node-node-access-cache-contexts-2.patch, failed testing. View results

arpad.rozsa’s picture

arpad.rozsa’s picture

Re-uploading the test only patch, because it was corrupted.

arpad.rozsa’s picture

https://www.drupal.org/project/drupal/issues/2883553
This issue was committed yesterday, so rerolling my patch to make it work with the latest changes. Otherwise nothing changed in the patch.

arpad.rozsa’s picture

Status: Needs work » Needs review
Berdir’s picture

Status: Needs review » Needs work
  1. +++ b/core/modules/node/tests/src/Functional/NodeCacheContextsTest.php
    @@ -0,0 +1,62 @@
    +
    +use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
    +
    +
    +/**
    

    one empty line too much here.

  2. +++ b/core/modules/node/tests/src/Functional/NodeCacheContextsTest.php
    @@ -0,0 +1,62 @@
    +
    +  /**
    +   * {@inheritdoc}
    +   */
    +  protected function setUp() {
    +    parent::setUp();
    +  }
    

    not needed.

  3. +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php
    @@ -37,12 +37,11 @@ public function testBigPipe() {
     
    -    // Node page: 3 placeholders:
    +    // Node page: 2 placeholders:
         // 1. messages
    -    // 2. local tasks block
    -    // 3. comment form
    +    // 2. comment form
         $this->drupalGet($node->toUrl());
    -    $this->assertBigPipePlaceholderReplacementCount(3);
    +    $this->assertBigPipePlaceholderReplacementCount(2);
       }
     
    

    This shows perfectly how this is a good thing.

    The local tasks no longer have to be placeholdered/varied-by-user until there actually *is* a user with only edit own permission and not edit all.

arpad.rozsa’s picture

Made all the changes according to your feedback.

Wim Leers’s picture

Title: node_node_access() is too eager in adding the user cache context. » node_node_access() is too eager in adding the user cache context — fixing this makes some pages faster
Status: Needs review » Needs work
Issue tags: -Needs tests +Needs issue summary update, +D8 cacheability

The issue summary

node_node_access() always adds the user cache context for the update/delete operations.

This is not true, because:

    case 'update':
      if ($account->hasPermission('edit any ' . $type . ' content')) {
        return AccessResult::allowed()->cachePerPermissions();
      }
      else {
        return AccessResult::allowedIf($account->hasPermission('edit own ' . $type . ' content') && ($account->id() == $node->getOwnerId()))->cachePerPermissions()->cachePerUser()->addCacheableDependency($node);
      }

If you enter the first branch, it cannot possibly vary by the user cache context.

You're right though that in the second branch it always vary by user, even when the user doesn't have that permission! Great find! 👏 😀

Still, the claim in the very first sentence of the issue summary is incorrect. Explaining this more clearly will help this issue get committed much faster.

The patch

#2: There's so much broken about this function... Wow!

  1. +++ b/core/modules/node/tests/src/Functional/NodeCacheContextsTest.php
    @@ -0,0 +1,54 @@
    + * Tests the Node entity's cache contexts.
    

    That's not what this is testing.

    We could fix this by just adding the sole test method of this class to \Drupal\Tests\node\Functional\NodeAccessAutoBubblingTest and perhaps renaming that test class, to NodeAccessCacheabilityTest.

  2. +++ b/core/modules/node/node.module
    @@ -946,22 +946,22 @@ function node_node_access(NodeInterface $node, $op, $account) {
    +      elseif ($account->hasPermission('edit own ' . $type . ' content')) {
    +        return AccessResult::allowedIf($account->id() == $node->getOwnerId())->cachePerPermissions()->cachePerUser()->addCacheableDependency($node);
           }
    +      break;
    ...
    +  // No opinion.
    +  return AccessResult::neutral();
    

    This means that the user.permissions cache context will incorrectly be missing if we don't enter the elseif branch.

  3. +++ b/core/modules/node/tests/src/Functional/NodeCacheContextsTest.php
    @@ -0,0 +1,54 @@
    +    $this->assertNoCacheContext('user');
    +    $this->drupalGet('node/' . $node2->id() . '/delete');
    +    $this->assertNoCacheContext('user');
    

    This should assert that the user.permissions cache context is present.

  4. +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php
    @@ -37,12 +37,11 @@ public function testBigPipe() {
    -    // Node page: 3 placeholders:
    +    // Node page: 2 placeholders:
         // 1. messages
    -    // 2. local tasks block
    -    // 3. comment form
    +    // 2. comment form
         $this->drupalGet($node->toUrl());
    -    $this->assertBigPipePlaceholderReplacementCount(3);
    +    $this->assertBigPipePlaceholderReplacementCount(2);
    

    NICE!!! This means fewer things to render on every page load, and therefore a slightly faster Drupal!

    It'd be nice if we'd have benchmarks for this and preferably even profiling, but that's not a requirement. It'd make for a cool release notes snippet :)

Great work! 🎉

Wim Leers’s picture

Priority: Normal » Major

Actually, for #9.3 / #12.4, I'm bumping this to Major 🤘

It's not a major improvement in and of itself … but it has a performance (and a small scalability) impact on just about every Drupal 8 site. Which is why it'd be great to benchmark it.

kristiaanvandeneynde’s picture

Agree with Wim on the user.permissions cache context needing to be set in case of 'update' or 'delete'.

So we can actually keep the default case returning a plain AccessResult::neutral(), but the delete and update case need to look like this:

if has any permission
  return access result cached by permissions
else if has own permission
  return access result cached by user (and permissions, but meh)
else
  return access result neutral cached by permissions

If we don't implement the fallback return value in the else statement and the first person to have their access checked happens to not have access, then we will cache a neutral result for everyone. So the tests definitely need to check this, as Wim said, to avoid regressions further down the line.

Berdir’s picture

True, right now that that actually can't fail in any way because that context is always theret. We also can't test for it, because \Drupal\node\NodeAccessControlHandler::access() does always add that anyway, and we wouldn't just call this specific hook.

Also, I'd just change it to store the the access result in $access and then always call $access->addCacheContext() after the if/elseif.

Wim Leers’s picture

Also, I'd just change it to store the the access result in $access and then always call

+1

Berdir’s picture

> It'd be nice if we'd have benchmarks for this and preferably even profiling, but that's not a requirement. It'd make for a cool release notes snippet :)

I did find this issue when profiling a project we're working on. With just core, it likely doesn't make a big difference, but as soon as you add node grants and a decent data set then things look very different, because having to check update/delete node access on every request can be pretty expensive then.

arpad.rozsa’s picture

Moved the added test method into the suggested class and made sure that the user.permissions cache contexts are always set, even if we don't enter the elseif.

Berdir’s picture

Issue summary: View changes
Status: Needs review » Reviewed & tested by the community
Issue tags: -Needs issue summary update

Updated issue summary to address the input from Wim, I also think the patch was updated based on the reviews.

The first patch here was written by me, but it changed quite a bit since then and has been reviewed by Wim. Setting to RTBC, but I'll also ping Wim to check this again.

catch’s picture

One very minor comment. Leaving RTBC (and also for Wim to have a last look).

+++ b/core/modules/node/node.module
@@ -933,33 +933,36 @@ function node_form_system_themes_admin_form_submit($form, FormStateInterface $fo
+      $access->addCacheContexts(['user.permissions']);

Do we explicitly need this given the allowedIfHasPermission() just above? I guess we do in case that code changes but maybe a comment for people copying the logic?

jibran’s picture

+++ b/core/modules/node/node.module
@@ -933,33 +933,36 @@ function node_form_system_themes_admin_form_submit($form, FormStateInterface $fo
-    default:
-      // No opinion.
-      return AccessResult::neutral();

This change reminds me of #3016038: Unrecognised entity operation passed to Menu Link Content throws exceptions.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 18: 3016781-node-node-access-cache-contexts-18.patch, failed testing. View results

Berdir’s picture

Status: Needs work » Reviewed & tested by the community

"CI job missing"

@catch: Hm, we only use allowedIfHasPermission() in the 'create' case, update/delete doesn't. So technically, we do need that. It's still pretty theoretical, because NodeAccessControlHandler adds that to each access result anyway and user.permissions in the end is a required cache context, but is the correct thing to do.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 18: 3016781-node-node-access-cache-contexts-18.patch, failed testing. View results

Mixologic’s picture

Status: Needs work » Reviewed & tested by the community

dispatcher issues. returning to rtbc

Version: 8.7.x-dev » 8.8.x-dev

Drupal 8.7.0-alpha1 will be released the week of March 11, 2019, which means new developments and disruptive changes should now be targeted against the 8.8.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

catch’s picture

Status: Reviewed & tested by the community » Needs review

@berdir OK that's a good point, but then why not use AccessResult::allowedIfHasPermission() in these cases too?

+++ b/core/modules/node/node.module
@@ -933,33 +933,36 @@ function node_form_system_themes_admin_form_submit($form, FormStateInterface $fo
     case 'create':
-      return AccessResult::allowedIfHasPermission($account, 'create ' . $type . ' content');
+      $access = AccessResult::allowedIfHasPermission($account, 'create ' . $type . ' content');
 
     case 'update':
       if ($account->hasPermission('edit any ' . $type . ' content')) {
-        return AccessResult::allowed()->cachePerPermissions();
+        $access = AccessResult::allowed();
       }
-      else {
-        return AccessResult::allowedIf($account->hasPermission('edit own ' . $type . ' content') && ($account->id() == $node->getOwnerId()))->cachePerPermissions()->cachePerUser()->addCacheableDependency($node);
+      elseif ($account->hasPermission('edit own ' . $type . ' content')) {
+        $access = AccessResult::allowedIf($account->id() == $node->getOwnerId())->cachePerUser()->addCacheableDependency($node);
       }
Berdir’s picture

@catch: And where exactly would we call that? We first do an elseif ($account->hasPermission('edit own ' . $type . ' content')), but the reason for doing that is that we always want to have that cache context, whether or not that condition is TRUE, that's why it was separated out.

Berdir’s picture

Status: Needs review » Reviewed & tested by the community

Setting back to RTBC because I don't see how see how that would make the patch easier. We'd need to assign $access inside the elseif () and then merge it with an andIf() inside the condition.

Again, adding it at all is purely theoretical anyway, it's always there anyway because NodeAccessControlHandler adds it to any access result it returns.

catch’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
8.32 KB
2.37 KB

OK I'm not sure if I'm not explaining my idea well, or if I am and you just think it's a bad one. So here it is. Passes the new test.

catch’s picture

This one should apply.

Status: Needs review » Needs work

The last submitted patch, 32: 3016781-32.patch, failed testing. View results

Berdir’s picture

+++ b/core/modules/node/node.module
@@ -974,33 +974,30 @@ function node_form_system_themes_admin_form_submit($form, FormStateInterface $fo
 
     case 'update':
-      if ($account->hasPermission('edit any ' . $type . ' content')) {
-        return AccessResult::allowed()->cachePerPermissions();
-      }
-      else {
-        return AccessResult::allowedIf($account->hasPermission('edit own ' . $type . ' content') && ($account->id() == $node->getOwnerId()))->cachePerPermissions()->cachePerUser()->addCacheableDependency($node);
+      $access = AccessResult::allowedIfHasPermission($account, 'edit any ' . $type . ' content');
+      if (!$access->isAllowed() && $account->hasPermission('edit own ' . $type . ' content')) {
+        $access = $access->orIf(AccessResult::allowedIf($account->id() == $node->getOwnerId())->cachePerUser()->addCacheableDependency($node));
       }
+      break;
 
     case 'delete':

I see, the orIf() part is a bit strange as you already know that access isn't allowed from that, we only do that to keep the cache context. But I like it.

the test fail might be a new test, will check.

Berdir’s picture

Status: Needs work » Needs review
FileSize
8.66 KB
831 bytes

Ok, the test isn't new, this actually does result in exposing that message, which seems like a good thing, even though it is not the only way you could have access.

It was however confusing that the expected/actual was inverted, fixing that by using assertSame().

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
  1. +++ b/core/modules/editor/tests/src/Functional/QuickEditIntegrationLoadingTest.php
    @@ -103,11 +103,11 @@ public function testUsersWithoutPermission() {
    -        $message = '';
    +        $message = "The 'edit any article content' permission is required.";
    

    🎉 — this is a nice side effect of using AccessResult and friends in the intended way :)

  2. +++ b/core/modules/node/node.module
    @@ -974,33 +974,30 @@ function node_form_system_themes_admin_form_submit($form, FormStateInterface $fo
    +      $access = AccessResult::allowedIfHasPermission($account, 'edit any ' . $type . ' content');
    +      if (!$access->isAllowed() && $account->hasPermission('edit own ' . $type . ' content')) {
    +        $access = $access->orIf(AccessResult::allowedIf($account->id() == $node->getOwnerId())->cachePerUser()->addCacheableDependency($node));
           }
    +      break;
    

    Excellent! Thanks @catch :) Like @Berdir, I like this too!

    Excellent pattern to follow elsewhere too.

  3. +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheabilityTest.php
    @@ -12,7 +12,7 @@
    -class NodeAccessAutoBubblingTest extends NodeTestBase {
    +class NodeAccessCacheabilityTest extends NodeTestBase {
    

    👍— much more accurate name indeed.

  4. +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheabilityTest.php
    @@ -67,4 +67,45 @@ public function testNodeAccessCacheabilitySafeguard() {
    +    // Create a user, with edit/delete own content permission.
    ...
    +    $this->drupalGet('node/' . $node1->id() . '/edit');
    +    $this->assertCacheContext('user');
    +    $this->drupalGet('node/' . $node1->id() . '/delete');
    +    $this->assertCacheContext('user');
    ...
    +    // Create a user without edit/delete permission.
    ...
    +    $this->drupalGet('node/' . $node2->id() . '/edit');
    +    $this->assertCacheContext('user.permissions');
    +    $this->drupalGet('node/' . $node2->id() . '/delete');
    +    $this->assertCacheContext('user.permissions');
    

    👌

  5. +++ b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php
    @@ -71,7 +71,6 @@ public function testPageCacheTags() {
    -      'user',
    
    @@ -81,6 +80,8 @@ public function testPageCacheTags() {
    +      'user.roles:anonymous',
    +      'user.roles:authenticated',
    
    @@ -107,7 +108,6 @@ public function testPageCacheTags() {
    -      'user:0',
    
    @@ -164,7 +164,6 @@ public function testPageCacheTags() {
    -      'user:0',
    
    +++ b/core/modules/views/tests/src/Kernel/Plugin/RowRenderCacheTest.php
    @@ -188,11 +188,10 @@ protected function doTestRenderedOutput(AccountInterface $account, $check_cache
    -        $user_context = !$account->hasPermission('edit any test content') ? 'user' : 'user.permissions';
    ...
    -            'contexts' => ['languages:language_interface', 'theme', $user_context],
    +            'contexts' => ['languages:language_interface', 'theme', 'user.permissions'],
    
    +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardJavascriptTest.php
    @@ -37,12 +37,11 @@ public function testBigPipe() {
    -    // Node page: 3 placeholders:
    +    // Node page: 2 placeholders:
         // 1. messages
    -    // 2. local tasks block
    -    // 3. comment form
    +    // 2. comment form
         $this->drupalGet($node->toUrl());
    -    $this->assertBigPipePlaceholderReplacementCount(3);
    +    $this->assertBigPipePlaceholderReplacementCount(2);
    

    🥳— this is showing all the big cacheability wins!

alexpott’s picture

Version: 8.8.x-dev » 8.7.x-dev
Status: Reviewed & tested by the community » Fixed

Committed and pushed 039eae1719 to 8.8.x and cd253e6860 to 8.7.x. Thanks!

  • alexpott committed 039eae1 on 8.8.x
    Issue #3016781 by arpad.rozsa, Berdir, catch, Wim Leers,...

  • alexpott committed cd253e6 on 8.7.x
    Issue #3016781 by arpad.rozsa, Berdir, catch, Wim Leers,...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.