From 3ce43cc2b110e110e4d77aff160c7ca012be92e6 Mon Sep 17 00:00:00 2001 From: Eric Smith <ericsmith@catalyst.net.nz> Date: Tue, 4 Feb 2025 09:45:09 +1300 Subject: [PATCH 1/9] Issue #3504114: Add WIP test to show error --- .../NodeAccessCacheRedirectWarning.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php new file mode 100644 index 000000000000..461d96f2ba37 --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +use Drupal\Core\Cache\CacheableMetadata; + +/** + * Tests the node access grants cache context service. + * + * @group node + * @group Cache + */ +class NodeAccessCacheRedirectWarning extends NodeTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'node_access_test_empty']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + node_access_rebuild(); + } + + /** + * Quick demonstration of the differences in cache contexts. + * + * The intent here was to visit the nodes to view the error but for whatever + * reason I can't seem to trigger the redirect warning this way. Needs work. + */ + public function testNodeAccessCacheRedirectWarning(): void { + $this->drupalPlaceBlock('local_tasks_block'); + + $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('node_grants')); + + $author = $this->drupalCreateUser([ + 'create page content', + 'edit own page content', + 'view own unpublished content', + ]); + $this->drupalLogin($author); + + $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]); + + $access = $node->access('view', $author, TRUE); + $unpublished_metadata = CacheableMetadata::createFromObject($access); + + // Reset the node access cache to check the published node. + \Drupal::entityTypeManager()->getAccessControlHandler('node')->resetCache(); + + $node->setPublished(); + $node->save(); + $access = $node->access('view', $author, TRUE); + $published_metadata = CacheableMetadata::createFromObject($access); + + $this->assertSame($unpublished_metadata->getCacheContexts(), $published_metadata->getCacheContexts()); + } + +} -- GitLab From 7c76520ea3264c996ab967687ae63892f26e25af Mon Sep 17 00:00:00 2001 From: Eric Smith <ericsmith@catalyst.net.nz> Date: Thu, 6 Mar 2025 18:08:15 +1300 Subject: [PATCH 2/9] Issue #3504114: If edit own content is added, then the cache context user is used, if edit any permissions then only user.permission is added --- .../NodeAccessCacheRedirectWarning.php | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php index 461d96f2ba37..5438ed7680c5 100644 --- a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php @@ -4,8 +4,6 @@ namespace Drupal\Tests\node\Functional; -use Drupal\Core\Cache\CacheableMetadata; - /** * Tests the node access grants cache context service. * @@ -46,25 +44,29 @@ public function testNodeAccessCacheRedirectWarning(): void { $author = $this->drupalCreateUser([ 'create page content', - 'edit own page content', + 'edit any page content', 'view own unpublished content', ]); $this->drupalLogin($author); $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]); - $access = $node->access('view', $author, TRUE); - $unpublished_metadata = CacheableMetadata::createFromObject($access); + $this->drupalGet($node->toUrl()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + + $node->setUnpublished(); + $node->save(); - // Reset the node access cache to check the published node. - \Drupal::entityTypeManager()->getAccessControlHandler('node')->resetCache(); + $this->drupalGet($node->toUrl()); $node->setPublished(); $node->save(); - $access = $node->access('view', $author, TRUE); - $published_metadata = CacheableMetadata::createFromObject($access); - $this->assertSame($unpublished_metadata->getCacheContexts(), $published_metadata->getCacheContexts()); + $this->drupalGet($node->toUrl()); } } -- GitLab From 9ad37e776a53bf5a44398941f231a21e189f86a3 Mon Sep 17 00:00:00 2001 From: catch <6915-catch@users.noreply.drupalcode.org> Date: Thu, 6 Mar 2025 09:32:11 +0000 Subject: [PATCH 3/9] Rename test. --- ...directWarning.php => NodeAccessCacheRedirectWarningTest.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename core/modules/node/tests/src/Functional/{NodeAccessCacheRedirectWarning.php => NodeAccessCacheRedirectWarningTest.php} (95%) diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php similarity index 95% rename from core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php rename to core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php index 5438ed7680c5..8946ca75fefe 100644 --- a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarning.php +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php @@ -10,7 +10,7 @@ * @group node * @group Cache */ -class NodeAccessCacheRedirectWarning extends NodeTestBase { +class NodeAccessCacheRedirectWarningTest extends NodeTestBase { /** * {@inheritdoc} -- GitLab From f9f1f2ba0f4b6534d80a7ce9c50dbb4fc5106846 Mon Sep 17 00:00:00 2001 From: catch <6915-catch@users.noreply.drupalcode.org> Date: Thu, 6 Mar 2025 10:20:41 +0000 Subject: [PATCH 4/9] Add extra cache context. --- core/modules/node/src/NodeGrantDatabaseStorage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/core/modules/node/src/NodeGrantDatabaseStorage.php b/core/modules/node/src/NodeGrantDatabaseStorage.php index eea6cc100127..9a4c77d2d9ab 100644 --- a/core/modules/node/src/NodeGrantDatabaseStorage.php +++ b/core/modules/node/src/NodeGrantDatabaseStorage.php @@ -121,6 +121,7 @@ public function access(NodeInterface $node, $operation, AccountInterface $accoun // know it for a fact. $set_cacheability = function (AccessResult $access_result) use ($operation) { $access_result->addCacheContexts(['user.node_grants:' . $operation]); + $access_result->addCacheContexts(['user.roles.authenticated']); if ($operation !== 'view') { $access_result->setCacheMaxAge(0); } -- GitLab From 711552bd9e03eed2ddc041f484ffb57ded6e85ce Mon Sep 17 00:00:00 2001 From: catch <6915-catch@users.noreply.drupalcode.org> Date: Thu, 6 Mar 2025 18:13:14 +0000 Subject: [PATCH 5/9] Move user.roles:authenticated cache context around. --- core/modules/node/src/NodeAccessControlHandler.php | 9 ++++++++- core/modules/node/src/NodeGrantDatabaseStorage.php | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php index 963ab53ded41..f4811bdb21dd 100644 --- a/core/modules/node/src/NodeAccessControlHandler.php +++ b/core/modules/node/src/NodeAccessControlHandler.php @@ -207,6 +207,14 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun // we need to add the node as a cacheable dependency. $cacheability->addCacheableDependency($node); + // Due to the check below, it is not possible to rely only on account + // permissions to determine whether the 'view own unpublished content' + // permission can be checked, instead we also need to check if the user has + // the authenticated role. Just in case anonymous and authenticated users + // are both granted the 'view own unpublished content' permission and also + // have otherwise identical permissions. + $cacheability->addCacheContext(['user.roles.authenticated']); + if ($node->isPublished()) { return NULL; } @@ -216,7 +224,6 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun return NULL; } - $cacheability->addCacheContexts(['user.roles:authenticated']); // The "view own unpublished content" permission must not be granted // to anonymous users for security reasons. if (!$account->isAuthenticated()) { diff --git a/core/modules/node/src/NodeGrantDatabaseStorage.php b/core/modules/node/src/NodeGrantDatabaseStorage.php index 9a4c77d2d9ab..eea6cc100127 100644 --- a/core/modules/node/src/NodeGrantDatabaseStorage.php +++ b/core/modules/node/src/NodeGrantDatabaseStorage.php @@ -121,7 +121,6 @@ public function access(NodeInterface $node, $operation, AccountInterface $accoun // know it for a fact. $set_cacheability = function (AccessResult $access_result) use ($operation) { $access_result->addCacheContexts(['user.node_grants:' . $operation]); - $access_result->addCacheContexts(['user.roles.authenticated']); if ($operation !== 'view') { $access_result->setCacheMaxAge(0); } -- GitLab From 025c6e98df936d134ec599b031133cfdbab9515b Mon Sep 17 00:00:00 2001 From: catch <6915-catch@users.noreply.drupalcode.org> Date: Thu, 6 Mar 2025 20:21:50 +0000 Subject: [PATCH 6/9] Add the node grants cache context in exactly one additional situation. --- core/modules/node/src/NodeAccessControlHandler.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php index f4811bdb21dd..2ee7d4965d20 100644 --- a/core/modules/node/src/NodeAccessControlHandler.php +++ b/core/modules/node/src/NodeAccessControlHandler.php @@ -213,12 +213,12 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun // the authenticated role. Just in case anonymous and authenticated users // are both granted the 'view own unpublished content' permission and also // have otherwise identical permissions. - $cacheability->addCacheContext(['user.roles.authenticated']); if ($node->isPublished()) { return NULL; } $cacheability->addCacheContexts(['user.permissions']); + $cacheability->addCacheContexts(['user.roles:authenticated']); if (!$account->hasPermission('view own unpublished content')) { return NULL; @@ -230,7 +230,17 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun return NULL; } + // When access is granted due to the 'view own unpublished content' + // permission and for no other reason, node grants are bypassed. However, + // to ensure the full set of cacheable metadata is available to variation + // cache, additionally add the node_grants cache context so that if the + // status or the owner of the node changes, cache redirects will continue to + // reflect the latest state without needing to be invalidated. $cacheability->addCacheContexts(['user']); + if ($this->moduleHandler->hasImplementations('node_grants')) { + $cacheability->addCacheContexts(['user.node_grants:view']); + } + if ($account->id() != $node->getOwnerId()) { return NULL; } -- GitLab From e4be93b0d7a393b80abc3662dce11e93f97ada9b Mon Sep 17 00:00:00 2001 From: catch <6915-catch@users.noreply.drupalcode.org> Date: Thu, 6 Mar 2025 20:57:58 +0000 Subject: [PATCH 7/9] Move things back where they were, but keep comment. --- .../node/src/NodeAccessControlHandler.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php index 2ee7d4965d20..743f614e61c8 100644 --- a/core/modules/node/src/NodeAccessControlHandler.php +++ b/core/modules/node/src/NodeAccessControlHandler.php @@ -207,23 +207,23 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun // we need to add the node as a cacheable dependency. $cacheability->addCacheableDependency($node); - // Due to the check below, it is not possible to rely only on account - // permissions to determine whether the 'view own unpublished content' - // permission can be checked, instead we also need to check if the user has - // the authenticated role. Just in case anonymous and authenticated users - // are both granted the 'view own unpublished content' permission and also - // have otherwise identical permissions. - if ($node->isPublished()) { return NULL; } $cacheability->addCacheContexts(['user.permissions']); - $cacheability->addCacheContexts(['user.roles:authenticated']); if (!$account->hasPermission('view own unpublished content')) { return NULL; } + // Due to the check below, it is not possible to rely only on account + // permissions to determine whether the 'view own unpublished content' + // permission can be checked, instead we also need to check if the user has + // the authenticated role. Just in case anonymous and authenticated users + // are both granted the 'view own unpublished content' permission and also + // have otherwise identical permissions. + $cacheability->addCacheContexts(['user.roles:authenticated']); + // The "view own unpublished content" permission must not be granted // to anonymous users for security reasons. if (!$account->isAuthenticated()) { -- GitLab From a993579b4eceb5ff4898e0847978cea6448acbb6 Mon Sep 17 00:00:00 2001 From: Kristiaan Van den Eynde <kristiaan@factorial.io> Date: Mon, 31 Mar 2025 10:50:14 +0200 Subject: [PATCH 8/9] Add PseudoCacheContext and DependencyVariation, do not implement yet. --- core/core.services.yml | 6 ++ .../Core/Cache/Context/PseudoCacheContext.php | 43 +++++++++ .../Drupal/Core/Cache/DependencyVariation.php | 87 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php create mode 100644 core/lib/Drupal/Core/Cache/DependencyVariation.php diff --git a/core/core.services.yml b/core/core.services.yml index bb73e29d505b..84abfc516c4b 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -152,6 +152,12 @@ services: tags: - { name: cache.context } + # Pseudo cache context, not intended for direct use. + cache_context.pseudo: + class: Drupal\Core\Cache\Context\PseudoCacheContext + tags: + - { name: cache.context } + # Complex cache contexts, that depend on the routing system. cache_context.route: class: Drupal\Core\Cache\Context\RouteCacheContext diff --git a/core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php b/core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php new file mode 100644 index 000000000000..7e1300bfa158 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/Context/PseudoCacheContext.php @@ -0,0 +1,43 @@ +<?php + +namespace Drupal\Core\Cache\Context; + +use Drupal\Core\Cache\CacheableMetadata; + +/** + * Defines the PseudoCacheContext service. + * + * This cache context will always return the value of '1', but is not intended + * to be added manually. Instead, users are expected to use the associated + * helper class \Drupal\Core\Cache\DependencyVariation instead. + * + * Cache context ID: 'pseudo'. + * + * @see \Drupal\Core\Cache\DependencyVariation + */ +class PseudoCacheContext implements CalculatedCacheContextInterface { + + const ID = 'pseudo'; + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return t('Pseudo'); + } + + /** + * {@inheritdoc} + */ + public function getContext($parameter = NULL) { + return 1; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($parameter = NULL) { + return new CacheableMetadata(); + } + +} diff --git a/core/lib/Drupal/Core/Cache/DependencyVariation.php b/core/lib/Drupal/Core/Cache/DependencyVariation.php new file mode 100644 index 000000000000..212b0590eb53 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/DependencyVariation.php @@ -0,0 +1,87 @@ +<?php + +namespace Drupal\Core\Cache; + +use Drupal\Core\Cache\Context\PseudoCacheContext; + +/** + * Helper class to add a pseudo cache context as a dependency. + * + * This is intended to be used when you have a code flow that varies based on a + * (property of a) dependency, but you have no way to represent said variation + * with a regular cache context. An example would be the passed in entity to an + * access control handler: You cannot know where this entity came from, so how + * would you know what cache context to use? + * + * You are supposed to use this helper class to wrap the original dependency and + * then pass in this class as a cacheable dependency itself. It will then + * compile the cache context identifier for you and add it to the cacheable + * metadata this helper class was added to. + * + * @ingroup cache + */ +class DependencyVariation implements CacheableDependencyInterface { + + use CacheableDependencyTrait; + + public function __construct(CacheableDependencyInterface $dependency) { + $this->cacheTags = $dependency->getCacheTags(); + $this->cacheMaxAge = $dependency->getCacheMaxAge(); + assert(empty($dependency->getCacheContexts()), 'A dependency passed into DependencyVariation should not contain any cache contexts.'); + sort($this->cacheTags); + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return []; + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + $identifier = implode(',', $this->cacheTags) . '|' . $this->cacheMaxAge; + return PseudoCacheContext::ID . ':' . $identifier; + } + + /** + * {@inheritdoc} + */ + public function getCacheMaxAge() { + return Cache::PERMANENT; + } + + /** + * Checks if a cache context identifier represents a pseudo cache context. + * + * @param string $cache_context + * The cache context identifier. + * + * @return bool + * Whether the cache context identifier is for a pseudo cache context. + */ + public static function isPseudoCacheContext(string $cache_context): bool { + return str_starts_with($cache_context, PseudoCacheContext::ID . ':'); + } + + /** + * Parses a pseudo cache context into a cacheable dependency. + * + * @param string $cache_context + * The cache context identifier. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheable metadata that was embedded in the identifier. Note that + * this will only ever contain cache tags and max-age. + */ + public static function parsePseudoCacheContext(string $cache_context): CacheableMetadata { + [$cache_context_id, $parameter] = explode(':', $cache_context, 2); + [$cache_tag_string, $max_age] = explode('|', $parameter); + return (new CacheableMetadata()) + ->addCacheTags(explode(',', $cache_tag_string)) + ->setCacheMaxAge($max_age); + } + +} -- GitLab From c39d91c02a6dbb94d9233b7086e090170ce3eab0 Mon Sep 17 00:00:00 2001 From: Kristiaan Van den Eynde <kristiaan@factorial.io> Date: Mon, 31 Mar 2025 11:09:43 +0200 Subject: [PATCH 9/9] phpcs, not sure if this will pass --- core/lib/Drupal/Core/Cache/DependencyVariation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/lib/Drupal/Core/Cache/DependencyVariation.php b/core/lib/Drupal/Core/Cache/DependencyVariation.php index 212b0590eb53..207df45fbd5a 100644 --- a/core/lib/Drupal/Core/Cache/DependencyVariation.php +++ b/core/lib/Drupal/Core/Cache/DependencyVariation.php @@ -77,7 +77,7 @@ public static function isPseudoCacheContext(string $cache_context): bool { * this will only ever contain cache tags and max-age. */ public static function parsePseudoCacheContext(string $cache_context): CacheableMetadata { - [$cache_context_id, $parameter] = explode(':', $cache_context, 2); + [, $parameter] = explode(':', $cache_context, 2); [$cache_tag_string, $max_age] = explode('|', $parameter); return (new CacheableMetadata()) ->addCacheTags(explode(',', $cache_tag_string)) -- GitLab