diff --git a/core/core.services.yml b/core/core.services.yml index bb73e29d505bc93dec5e23853174d9127cdd04e0..84abfc516c4b0792ecfef0c5afcbab4620e0f176 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 0000000000000000000000000000000000000000..7e1300bfa15883ada2378416d4fa2c1ac947c8ba --- /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 0000000000000000000000000000000000000000..207df45fbd5a1f14ae9879365a87031d40edf6c6 --- /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 { + [, $parameter] = explode(':', $cache_context, 2); + [$cache_tag_string, $max_age] = explode('|', $parameter); + return (new CacheableMetadata()) + ->addCacheTags(explode(',', $cache_tag_string)) + ->setCacheMaxAge($max_age); + } + +} diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php index 963ab53ded4129547c9480f9d29937de2ff7b21a..743f614e61c8c1932d5fe2d676e8e167e440f1de 100644 --- a/core/modules/node/src/NodeAccessControlHandler.php +++ b/core/modules/node/src/NodeAccessControlHandler.php @@ -216,14 +216,31 @@ protected function checkViewAccess(NodeInterface $node, AccountInterface $accoun 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()) { 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; } diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8946ca75fefeee4734ce41eafc516ac136b8a5d7 --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +/** + * Tests the node access grants cache context service. + * + * @group node + * @group Cache + */ +class NodeAccessCacheRedirectWarningTest 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 any page content', + 'view own unpublished content', + ]); + $this->drupalLogin($author); + + $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]); + + $this->drupalGet($node->toUrl()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + + $node->setUnpublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + } + +}