core/lib/Drupal/Core/Access/AccessResult.php | 17 ++++-- .../Core/Entity/Entity/EntityViewDisplay.php | 14 ++++- .../Core/Entity/EntityAccessControlHandler.php | 6 +-- .../lib/Drupal/Core/Entity/EntityTypeInterface.php | 2 +- core/lib/Drupal/Core/Entity/EntityViewBuilder.php | 3 -- core/lib/Drupal/Core/Field/FormatterBase.php | 11 +++- .../EntityReferenceEntityFormatter.php | 2 +- .../EntityReferenceFormatterBase.php | 38 ++++++++++--- .../FieldFormatter/EntityReferenceIdFormatter.php | 2 +- .../EntityReferenceLabelFormatter.php | 3 +- core/lib/Drupal/Core/Render/BubbleableMetadata.php | 38 +++++++++++++ .../aggregator/src/Tests/ItemCacheTagsTest.php | 10 ++++ .../src/Tests/BlockContentCacheTagsTest.php | 13 ++++- .../comment/src/Tests/CommentCacheTagsTest.php | 10 ++++ .../EntityReferenceFormatterTest.php | 14 ++++- .../Field/FieldFormatter/FileFormatterBase.php | 10 +++- .../Field/FieldFormatter/GenericFileFormatter.php | 2 +- .../Field/FieldFormatter/RSSEnclosureFormatter.php | 4 +- .../Plugin/Field/FieldFormatter/TableFormatter.php | 2 +- .../Field/FieldFormatter/UrlPlainFormatter.php | 2 +- .../filter/src/Tests/FilterFormatAccessTest.php | 2 +- .../Plugin/Field/FieldFormatter/ImageFormatter.php | 2 +- .../Field/FieldFormatter/ImageFormatterBase.php | 13 ++++- .../modules/node/src/Tests/Views/FrontPageTest.php | 1 - .../FieldFormatter/ResponsiveImageFormatter.php | 2 +- core/modules/system/entity.api.php | 1 + .../src/Tests/Entity/EntityCacheTagsTestBase.php | 62 +++++++++++----------- .../src/Tests/Entity/EntityViewBuilderTest.php | 14 ++--- .../Entity/EntityWithUriCacheTagsTestBase.php | 8 ++- .../EntityReferenceTaxonomyTermRssFormatter.php | 2 +- .../Drupal/Tests/Core/Access/AccessResultTest.php | 31 +++++++++++ 31 files changed, 262 insertions(+), 79 deletions(-) diff --git a/core/lib/Drupal/Core/Access/AccessResult.php b/core/lib/Drupal/Core/Access/AccessResult.php index bb76821..8c370dc 100644 --- a/core/lib/Drupal/Core/Access/AccessResult.php +++ b/core/lib/Drupal/Core/Access/AccessResult.php @@ -384,11 +384,22 @@ public function cacheUntilConfigurationChanges(ConfigBase $configuration) { */ public function orIf(AccessResultInterface $other) { // $other's cacheability metadata is merged if $merge_other gets set to TRUE - // and this happens in two cases: + // and this happens in three cases: // 1. $other's access result is the one that determines the combined access // result. // 2. This access result is not cacheable and $other's access result is the // same. i.e. attempt to return a cacheable access result. + // 3. Neither access result is 'forbidden' and both are cacheable: inherit + // the other's cacheability metadata because it may turn into a + // 'forbidden' for another value of the cache contexts in the + // cacheability metadata. In other words: this is necessary to respect + // the contagious nature of the 'forbidden' access result. + // e.g. we have two access results A and B. Neither is forbidden. A is + // globally cacheable (no cache contexts). B is cacheable per role. If we + // don't have merging case 3, then A->orIf(B) will be globally cacheable, + // which means that even if a user of a different role logs in, the + // cached access result will be used, even though for that other role, B + // is forbidden! $merge_other = FALSE; if ($this->isForbidden() || $other->isForbidden()) { $result = static::forbidden(); @@ -398,13 +409,13 @@ public function orIf(AccessResultInterface $other) { } elseif ($this->isAllowed() || $other->isAllowed()) { $result = static::allowed(); - if (!$this->isAllowed() || (!$this->isCacheable() && $other->isAllowed())) { + if (!$this->isAllowed() || (!$this->isCacheable() && $other->isAllowed()) || ($this->isCacheable() && $other instanceof CacheableInterface && $other->isCacheable())) { $merge_other = TRUE; } } else { $result = static::neutral(); - if (!$this->isNeutral() || (!$this->isCacheable() && $other->isNeutral())) { + if (!$this->isNeutral() || (!$this->isCacheable() && $other->isNeutral()) || ($this->isCacheable() && $other instanceof CacheableInterface && $other->isCacheable())) { $merge_other = TRUE; } } diff --git a/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php b/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php index 79c0f13..accdd92 100644 --- a/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php +++ b/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php @@ -13,6 +13,7 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\EntityDisplayBase; +use Drupal\Core\Render\BubbleableMetadata; /** * Configuration entity that contains display options for all components of a @@ -238,8 +239,17 @@ public function buildMultiple(array $entities) { // Then let the formatter build the output for each entity. foreach ($entities as $id => $entity) { $items = $grouped_items[$id]; - $build_list[$id][$name] = $formatter->view($items); - $build_list[$id][$name]['#access'] = $items->access('view'); + /** @var \Drupal\Core\Access\AccessResultInterface $field_access */ + $field_access = $items->access('view', NULL, TRUE); + $build_list[$id][$name] = []; + if ($field_access->isAllowed()) { + $build_list[$id][$name] = $formatter->view($items); + } + // Apply the field access cacheability metadata to the render array. + // @todo Use RendererInterface::addDependency() when https://www.drupal.org/node/2444231 lands + BubbleableMetadata::createFromRenderArray($build_list[$id][$name]) + ->merge(BubbleableMetadata::createFromAccessResult($field_access)) + ->applyTo($build_list[$id][$name]); } } } diff --git a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php index 8a25c2a..c34cffc 100644 --- a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php @@ -218,9 +218,9 @@ public function createAccess($entity_bundle = NULL, AccountInterface $account = // Invoke hook_entity_create_access() and hook_ENTITY_TYPE_create_access(). // Hook results take precedence over overridden implementations of - // EntityAccessControlHandler::checkAccess(). Entities that have checks that - // need to be done before the hook is invoked should do so by overriding - // this method. + // EntityAccessControlHandler::checkCreateAccess(). Entities that have + // checks that need to be done before the hook is invoked should do so by + // overriding this method. // We grant access to the entity if both of these conditions are met: // - No modules say to deny access. diff --git a/core/lib/Drupal/Core/Entity/EntityTypeInterface.php b/core/lib/Drupal/Core/Entity/EntityTypeInterface.php index 101e372..181cf39 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeInterface.php @@ -660,7 +660,7 @@ public function setUriCallback($callback); * * Enables code listing entities of this type to ensure that rendered listings * are varied as necessary, typically to ensure users of role A see other - * entities listed as users of role B. + * entities listed than users of role B. * * @return string[] */ diff --git a/core/lib/Drupal/Core/Entity/EntityViewBuilder.php b/core/lib/Drupal/Core/Entity/EntityViewBuilder.php index d12d028..5fc4db7 100644 --- a/core/lib/Drupal/Core/Entity/EntityViewBuilder.php +++ b/core/lib/Drupal/Core/Entity/EntityViewBuilder.php @@ -169,9 +169,6 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco // Collect cache defaults for this entity. '#cache' => array( 'tags' => Cache::mergeTags($this->getCacheTags(), $entity->getCacheTags()), - 'contexts' => [ - 'user.roles', - ], ), ); diff --git a/core/lib/Drupal/Core/Field/FormatterBase.php b/core/lib/Drupal/Core/Field/FormatterBase.php index f7df601..fa064ba 100644 --- a/core/lib/Drupal/Core/Field/FormatterBase.php +++ b/core/lib/Drupal/Core/Field/FormatterBase.php @@ -8,6 +8,7 @@ namespace Drupal\Core\Field; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element; /** * Base class for 'Field formatter' plugin implementations. @@ -79,7 +80,9 @@ public function view(FieldItemListInterface $items) { $addition = array(); $elements = $this->viewElements($items); - if ($elements) { + + // If there are actual renderable children, use #theme => field. + if (count($elements) !== count(Element::properties($elements))) { $entity = $items->getEntity(); $entity_type = $entity->getEntityTypeId(); $field_name = $this->fieldDefinition->getName(); @@ -101,6 +104,12 @@ public function view(FieldItemListInterface $items) { $addition = array_merge($info, $elements); } + // Otherwise, we are dealing with access cacheability metadata only (i.e. + // only the #cache property is set). Pass this, so that the necessary cache + // contexts and tags are bubbled. + elseif ($elements) { + $addition = $elements; + } return $addition; } diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceEntityFormatter.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceEntityFormatter.php index 1328f65..6155d10 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceEntityFormatter.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceEntityFormatter.php @@ -123,7 +123,7 @@ public function viewElements(FieldItemListInterface $items) { $view_mode = $this->getSetting('view_mode'); $elements = array(); - foreach ($this->getEntitiesToView($items) as $delta => $entity) { + foreach ($this->getEntitiesToView($elements, $items) as $delta => $entity) { // Protect ourselves from recursive rendering. static $depth = 0; $depth++; diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php index 25c802c..264bedf 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php @@ -7,9 +7,11 @@ namespace Drupal\Core\Field\Plugin\Field\FieldFormatter; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; use Drupal\Core\Field\FormatterBase; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\TypedData\TranslatableInterface; /** @@ -31,19 +33,24 @@ * is available in $entity->_referringItem. This is useful for field types * that store additional values next to the reference itself. * + * @param array $build + * The renderable array that will contain the rendered field. Will be + * updated by reference to set cacheability metadata (#cache). * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items * The item list. * * @return \Drupal\Core\Entity\EntityInterface[] * The array of referenced entities to display, keyed by delta. */ - protected function getEntitiesToView(EntityReferenceFieldItemListInterface $items) { + protected function getEntitiesToView(array &$build, EntityReferenceFieldItemListInterface $items) { $entities = array(); + $access_cacheability = new BubbleableMetadata(); $parent_entity_langcode = $items->getEntity()->language()->getId(); foreach ($items as $delta => $item) { // Ignore items where no entity could be loaded in prepareView(). if (!empty($item->_loaded)) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ $entity = $item->entity; // Set the entity in the correct language for display. @@ -51,8 +58,9 @@ protected function getEntitiesToView(EntityReferenceFieldItemListInterface $item $entity = $entity->getTranslation($parent_entity_langcode); } - // Check entity access if needed. - if (!$this->needsAccessCheck($item) || $entity->access('view')) { + $access = $this->checkAccess($item); + $access_cacheability = $access_cacheability->merge(BubbleableMetadata::createFromAccessResult($access)); + if ($access->isAllowed()) { // Add the referring item, in case the formatter needs it. $entity->_referringItem = $items[$delta]; $entities[$delta] = $entity; @@ -60,6 +68,14 @@ protected function getEntitiesToView(EntityReferenceFieldItemListInterface $item } } + // Apply the cacheability metadata for all referenced entities. This causes + // this field to be rendered (and cached) according to the cache contexts by + // which the access results vary, to ensure only users with access to this + // field can view it. It also tags this field with the cache tags on which + // the access results depend, to ensure users that cannot view this field at + // the moment will gain access once any of those cache tags are invalidated. + $access_cacheability->applyTo($build); + return $entities; } @@ -121,16 +137,22 @@ protected function needsEntityLoad(EntityReferenceItem $item) { } /** - * Returns whether entity access should be checked. + * Checks access to the given entity reference item. + * + * By default, entity access is checked. However, a subclass can choose to + * exclude certain items from entity access checking by immediately granting + * access. * * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item * The item to check. * - * @return bool - * TRUE if entity access should be checked. + * @return \Drupal\Core\Access\AccessResult + * A cacheable access result. */ - protected function needsAccessCheck(EntityReferenceItem $item) { - return TRUE; + protected function checkAccess(EntityReferenceItem $item) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $item->entity; + return $entity->access('view', NULL, TRUE); } } diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceIdFormatter.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceIdFormatter.php index 69b5b9f..cb8a65e 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceIdFormatter.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceIdFormatter.php @@ -30,7 +30,7 @@ class EntityReferenceIdFormatter extends EntityReferenceFormatterBase { public function viewElements(FieldItemListInterface $items) { $elements = array(); - foreach ($this->getEntitiesToView($items) as $delta => $entity) { + foreach ($this->getEntitiesToView($elements, $items) as $delta => $entity) { if ($entity->id()) { $elements[$delta] = array( '#markup' => SafeMarkup::checkPlain($entity->id()), diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php index c362a80..c29194c 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php @@ -64,7 +64,8 @@ public function viewElements(FieldItemListInterface $items) { $elements = array(); $output_as_link = $this->getSetting('link'); - foreach ($this->getEntitiesToView($items) as $delta => $entity) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + foreach ($this->getEntitiesToView($elements, $items) as $delta => $entity) { $label = $entity->label(); // If the link is to be displayed and the entity has a uri, display a // link. diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php index d822d82..ccf11fd 100644 --- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php +++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php @@ -8,7 +8,9 @@ namespace Drupal\Core\Render; use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableInterface; /** * Value object used for bubbleable rendering metadata. @@ -108,6 +110,42 @@ public static function createFromRenderArray(array $build) { } /** + * Creates a bubbleable metadata object from an access result. + * + * @param \Drupal\Core\Access\AccessResultInterface $access_result + * An access result. + * + * @return static + */ + public static function createFromAccessResult(AccessResultInterface $access_result) { + if ($access_result instanceof CacheableInterface) { + return static::createFromCacheableObject($access_result); + } + + // Any AccessResultInterface object that doesn't implement + // CacheableInterface must be assumed to be uncacheable. + $meta = new static(); + $meta->maxAge = 0; + return $meta; + } + + /** + * Creates a bubbleable metadata object from a cacheable object. + * + * @param \Drupal\Core\Cache\CacheableInterface $object + * A cacheable object. + * + * @return static + */ + public static function createFromCacheableObject(CacheableInterface $object) { + $meta = new static(); + $meta->contexts = $object->getCacheContexts(); + $meta->tags = $object->getCacheTags(); + $meta->maxAge = $object->getCacheMaxAge(); + return $meta; + } + + /** * Gets cache tags. * * @return string[] diff --git a/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php b/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php index 925a6ed..fd6fdee 100644 --- a/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php +++ b/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php @@ -9,6 +9,7 @@ use Drupal\aggregator\Entity\Feed; use Drupal\aggregator\Entity\Item; +use Drupal\Core\Entity\EntityInterface; use Drupal\system\Tests\Entity\EntityCacheTagsTestBase; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; @@ -84,4 +85,13 @@ public function testEntityCreation() { $this->assertFalse(\Drupal::cache('render')->get('foo'), 'Creating a new feed item invalidates the cache tag of the feed.'); } + /** + * {@inheritdoc} + */ + protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) { + return [ + 'user.permissions', + ]; + } + } diff --git a/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php b/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php index 8fb80d5..582d832 100644 --- a/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php +++ b/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php @@ -55,6 +55,15 @@ protected function createEntity() { /** * {@inheritdoc} * + * @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess() + */ + protected function getAccessCacheContextsForEntity(EntityInterface $entity) { + return []; + } + + /** + * {@inheritdoc} + * * Each comment must have a comment body, which always has a text format. */ protected function getAdditionalCacheTagsForEntity(EntityInterface $entity) { @@ -86,12 +95,12 @@ public function testBlock() { // Expected contexts and tags for the BlockContent entity. // @see \Drupal\Core\Entity\EntityViewBuilder::getBuildDefaults(). - $expected_entity_cache_contexts = ['theme', 'user.roles']; + $expected_entity_cache_contexts = ['theme']; $expected_entity_cache_tags = Cache::mergeTags(['block_content_view'], $this->entity->getCacheTags(), $this->getAdditionalCacheTagsForEntity($this->entity)); // Verify that what was render cached matches the above expectations. $cid = $this->createCacheId($expected_block_cache_keys, $expected_block_cache_contexts); $redirected_cid = $this->createCacheId($expected_block_cache_keys, Cache::mergeContexts($expected_block_cache_contexts, $expected_entity_cache_contexts)); - $this->verifyRenderCache($cid, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), $redirected_cid); + $this->verifyRenderCache($cid, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), ($cid !== $redirected_cid) ? $redirected_cid : NULL); } } diff --git a/core/modules/comment/src/Tests/CommentCacheTagsTest.php b/core/modules/comment/src/Tests/CommentCacheTagsTest.php index fa95260..d4710f5 100644 --- a/core/modules/comment/src/Tests/CommentCacheTagsTest.php +++ b/core/modules/comment/src/Tests/CommentCacheTagsTest.php @@ -81,6 +81,16 @@ protected function createEntity() { return $comment; } + + /** + * {@inheritdoc} + */ + protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) { + return [ + 'user.permissions', + ]; + } + /** * {@inheritdoc} * diff --git a/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php b/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php index 41f3fea..7d3a85b 100644 --- a/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php +++ b/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php @@ -10,6 +10,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\entity_reference\Tests\EntityReferenceTestTrait; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\field\Entity\FieldStorageConfig; use Drupal\filter\Entity\FilterFormat; use Drupal\system\Tests\Entity\EntityUnitTestBase; @@ -218,6 +219,14 @@ public function testLabelFormatter() { // The 'link' settings is TRUE by default. $build = $this->buildRenderArray([$this->referencedEntity, $this->unsavedReferencedEntity], $formatter); + $expected_field_cacheability = [ + 'contexts' => [ + 'user.permissions', + ], + 'tags' => [], + 'max-age' => Cache::PERMANENT, + ]; + $this->assertEqual($build['#cache'], $expected_field_cacheability, 'The field render array contains the entity access cacheability metadata'); $expected_item_1 = array( '#type' => 'link', '#title' => $this->referencedEntity->label(), @@ -226,8 +235,11 @@ public function testLabelFormatter() { '#cache' => array( 'tags' => $this->referencedEntity->getCacheTags(), ), + '#attached' => [], + '#post_render_cache' => [], ); $this->assertEqual(drupal_render($build[0]), drupal_render($expected_item_1), sprintf('The markup returned by the %s formatter is correct for an item with a saved entity.', $formatter)); + $this->assertEqual(BubbleableMetadata::createFromRenderArray($build[0]), BubbleableMetadata::createFromRenderArray($expected_item_1)); // The second referenced entity is "autocreated", therefore not saved and // lacking any URL info. @@ -237,7 +249,7 @@ public function testLabelFormatter() { 'tags' => $this->unsavedReferencedEntity->getCacheTags(), ), ); - $this->assertEqual($build[1], $expected_item_2, sprintf('The markup returned by the %s formatter is correct for an item with a unsaved entity.', $formatter)); + $this->assertEqual($build[1], $expected_item_2, sprintf('The render array returned by the %s formatter is correct for an item with a unsaved entity.', $formatter)); // Test with the 'link' setting set to FALSE. $build = $this->buildRenderArray([$this->referencedEntity, $this->unsavedReferencedEntity], $formatter, array('link' => FALSE)); diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php b/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php index cdd5b7b..3180571 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php @@ -7,6 +7,7 @@ namespace Drupal\file\Plugin\Field\FieldFormatter; +use Drupal\Core\Access\AccessResult; use Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceFormatterBase; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; @@ -25,11 +26,16 @@ protected function needsEntityLoad(EntityReferenceItem $item) { /** * {@inheritdoc} */ - protected function needsAccessCheck(EntityReferenceItem $item) { + protected function checkAccess(EntityReferenceItem $item) { // Only check access if the current file access control handler explicitly // opts in by implementing FileAccessFormatterControlHandlerInterface. $access_handler_class = $item->entity->getEntityType()->getHandlerClass('access'); - return is_subclass_of($access_handler_class, '\Drupal\file\FileAccessFormatterControlHandlerInterface'); + if (is_subclass_of($access_handler_class, '\Drupal\file\FileAccessFormatterControlHandlerInterface')) { + return parent::checkAccess($item); + } + else { + return AccessResult::allowed(); + } } } diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/GenericFileFormatter.php b/core/modules/file/src/Plugin/Field/FieldFormatter/GenericFileFormatter.php index cb917eb..806f80a 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/GenericFileFormatter.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/GenericFileFormatter.php @@ -28,7 +28,7 @@ class GenericFileFormatter extends FileFormatterBase { public function viewElements(FieldItemListInterface $items) { $elements = array(); - foreach ($this->getEntitiesToView($items) as $delta => $file) { + foreach ($this->getEntitiesToView($elements, $items) as $delta => $file) { $item = $file->_referringItem; $elements[$delta] = array( '#theme' => 'file_link', diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php b/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php index c324702..a8d6ec0 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php @@ -26,10 +26,11 @@ class RSSEnclosureFormatter extends FileFormatterBase { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items) { + $elements = []; $entity = $items->getEntity(); // Add the first file as an enclosure to the RSS item. RSS allows only one // enclosure per item. See: http://en.wikipedia.org/wiki/RSS_enclosure - foreach ($this->getEntitiesToView($items) as $delta => $file) { + foreach ($this->getEntitiesToView($elements, $items) as $delta => $file) { $entity->rss_elements[] = array( 'key' => 'enclosure', 'attributes' => array( @@ -39,6 +40,7 @@ public function viewElements(FieldItemListInterface $items) { ), ); } + return $elements; } } diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/TableFormatter.php b/core/modules/file/src/Plugin/Field/FieldFormatter/TableFormatter.php index 460450d..fa4ed24 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/TableFormatter.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/TableFormatter.php @@ -28,7 +28,7 @@ class TableFormatter extends FileFormatterBase { public function viewElements(FieldItemListInterface $items) { $elements = array(); - if ($files = $this->getEntitiesToView($items)) { + if ($files = $this->getEntitiesToView($elements, $items)) { $header = array(t('Attachment'), t('Size')); $rows = array(); foreach ($files as $delta => $file) { diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/UrlPlainFormatter.php b/core/modules/file/src/Plugin/Field/FieldFormatter/UrlPlainFormatter.php index 26b78f7..39df54b 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/UrlPlainFormatter.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/UrlPlainFormatter.php @@ -28,7 +28,7 @@ class UrlPlainFormatter extends FileFormatterBase { public function viewElements(FieldItemListInterface $items) { $elements = array(); - foreach ($this->getEntitiesToView($items) as $delta => $file) { + foreach ($this->getEntitiesToView($elements, $items) as $delta => $file) { $elements[$delta] = array('#markup' => file_create_url($file->getFileUri())); } diff --git a/core/modules/filter/src/Tests/FilterFormatAccessTest.php b/core/modules/filter/src/Tests/FilterFormatAccessTest.php index 8dfe449..32d5d04 100644 --- a/core/modules/filter/src/Tests/FilterFormatAccessTest.php +++ b/core/modules/filter/src/Tests/FilterFormatAccessTest.php @@ -126,7 +126,7 @@ function testFormatPermissions() { $this->assertTrue($this->allowedFormat->access('use', $this->webUser), 'A regular user has access to use a text format they were granted access to.'); $this->assertEqual(AccessResult::allowed()->addCacheContexts(['user.permissions']), $this->allowedFormat->access('use', $this->webUser, TRUE), 'A regular user has access to use a text format they were granted access to.'); $this->assertFalse($this->disallowedFormat->access('use', $this->webUser), 'A regular user does not have access to use a text format they were not granted access to.'); - $this->assertEqual(AccessResult::neutral(), $this->disallowedFormat->access('use', $this->webUser, TRUE)); //, 'A regular user does not have access to use a text format they were not granted access to.'); + $this->assertEqual(AccessResult::neutral()->cachePerPermissions(), $this->disallowedFormat->access('use', $this->webUser, TRUE), 'A regular user does not have access to use a text format they were not granted access to.'); $this->assertTrue($fallback_format->access('use', $this->webUser), 'A regular user has access to use the fallback format.'); $this->assertEqual(AccessResult::allowed(), $fallback_format->access('use', $this->webUser, TRUE), 'A regular user has access to use the fallback format.'); diff --git a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php index 38a163c..54b378f 100644 --- a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php +++ b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatter.php @@ -167,7 +167,7 @@ public function settingsSummary() { */ public function viewElements(FieldItemListInterface $items) { $elements = array(); - $files = $this->getEntitiesToView($items); + $files = $this->getEntitiesToView($elements, $items); // Early opt-out if the field is empty. if (empty($files)) { diff --git a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php index dd8348e..dad8275 100644 --- a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php +++ b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php @@ -8,6 +8,7 @@ namespace Drupal\image\Plugin\Field\FieldFormatter; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\field\FieldConfigInterface; use Drupal\file\Plugin\Field\FieldFormatter\FileFormatterBase; @@ -19,7 +20,7 @@ /** * {@inheritdoc} */ - protected function getEntitiesToView(EntityReferenceFieldItemListInterface $items) { + protected function getEntitiesToView(array &$build, EntityReferenceFieldItemListInterface $items) { // Add the default image if needed. if ($items->isEmpty()) { $default_image = $this->getFieldSetting('default_image'); @@ -45,10 +46,18 @@ protected function getEntitiesToView(EntityReferenceFieldItemListInterface $item '_is_default' => TRUE, )); $file->_referringItem = $items[0]; + + // Add the fallback image's cache tags to the render array, so that if + // the fallback image is modified, all render cached fields displaying + // the fallback image are updated. + // @todo Use RendererInterface::addDependency() when https://www.drupal.org/node/2444231 lands + BubbleableMetadata::createFromRenderArray($build) + ->merge(new BubbleableMetadata([], $file->getCacheTags())) + ->applyTo($build); } } - return parent::getEntitiesToView($items); + return parent::getEntitiesToView($build, $items); } } diff --git a/core/modules/node/src/Tests/Views/FrontPageTest.php b/core/modules/node/src/Tests/Views/FrontPageTest.php index 1348949..68f5094 100644 --- a/core/modules/node/src/Tests/Views/FrontPageTest.php +++ b/core/modules/node/src/Tests/Views/FrontPageTest.php @@ -290,7 +290,6 @@ protected function assertFrontPageViewCacheTags($do_assert_views_caches) { } $cache_contexts = Cache::mergeContexts($cache_contexts, [ 'timezone', - 'user.roles' ]); // First page. diff --git a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php index bc8cb93..edaee7d 100644 --- a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php +++ b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php @@ -175,7 +175,7 @@ public function settingsSummary() { */ public function viewElements(FieldItemListInterface $items) { $elements = array(); - $files = $this->getEntitiesToView($items); + $files = $this->getEntitiesToView($elements, $items); // Early opt-out if the field is empty. if (empty($files)) { diff --git a/core/modules/system/entity.api.php b/core/modules/system/entity.api.php index cd0c664..202cf9f 100644 --- a/core/modules/system/entity.api.php +++ b/core/modules/system/entity.api.php @@ -1844,6 +1844,7 @@ function hook_entity_field_access($operation, \Drupal\Core\Field\FieldDefinition if ($field_definition->getName() == 'field_of_interest' && $operation == 'edit') { return AccessResult::allowedIfHasPermission($account, 'update field of interest'); } + return AccessResult::neutral(); } /** diff --git a/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php b/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php index df0b6d7..d9c4ef3 100644 --- a/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php +++ b/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php @@ -130,6 +130,21 @@ protected static function generateStandardizedInfo($entity_type_label, $group) { abstract protected function createEntity(); /** + * Returns the access cache contexts for the tested entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to be tested, as created by createEntity(). + * + * @return string[] + * An array of the additional cache contexts. + * + * @see \Drupal\Core\Entity\EntityAccessControlHandlerInterface + */ + protected function getAccessCacheContextsForEntity(EntityInterface $entity) { + return ['user.permissions']; + } + + /** * Returns the additional (non-standard) cache contexts for the tested entity. * * @param \Drupal\Core\Entity\EntityInterface $entity @@ -317,7 +332,7 @@ public function testReferencedEntity() { // The default cache contexts for rendered entities. $default_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']; - $entity_cache_contexts = Cache::mergeContexts($default_cache_contexts, ['user.roles']); + $entity_cache_contexts = $default_cache_contexts; // Cache tags present on every rendered page. $page_cache_tags = Cache::mergeTags( @@ -370,7 +385,11 @@ public function testReferencedEntity() { // Also verify the existence of an entity render cache entry. $cache_keys = ['entity_view', 'entity_test', $this->referencing_entity->id(), 'full']; $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); - $redirected_cid = $this->createRedirectedCacheId($cache_keys, $entity_cache_contexts); + $access_cache_contexts = $this->getAccessCacheContextsForEntity($this->entity); + $redirected_cid = NULL; + if (count($access_cache_contexts)) { + $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $this->getAdditionalCacheContextsForEntity($this->referencing_entity), $access_cache_contexts)); + } $this->verifyRenderCache($cid, $referencing_entity_cache_tags, $redirected_cid); $this->pass("Test non-referencing entity.", 'Debug'); @@ -639,32 +658,6 @@ protected function createCacheId(array $keys, array $contexts) { } /** - * Creates the redirected cache ID, if any. - * - * If a subclass overrides ::getAdditionalCacheContextsForEntity(), it can - * specify the additional cache contexts by which the given entity must be - * varied, because those are the cache contexts that are bubbled from the - * field formatters. - * - * @param string[] $keys - * A list of cache keys used for the regular (pre-bubbling) CID. - * @param string[] $contexts - * A set of cache contexts used for the regular (pre-bubbling) CID. - * - * @return string|null - * The redirected (post-bubbling) CID, if any. - */ - protected function createRedirectedCacheId(array $keys, array $contexts) { - $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->referencing_entity); - if (count($additional_cache_contexts)) { - return $this->createCacheId($keys, Cache::mergeContexts($contexts, $additional_cache_contexts)); - } - else { - return NULL; - } - } - - /** * Verify that a given render cache entry exists, with the correct cache tags. * * @param string $cid @@ -681,12 +674,21 @@ protected function verifyRenderCache($cid, array $tags, $redirected_cid = NULL) sort($cache_entry->tags); sort($tags); $this->assertIdentical($cache_entry->tags, $tags); + $is_redirecting_cache_item = isset($cache_entry->data['#cache_redirect']); if ($redirected_cid === NULL) { - $this->assertTrue(!isset($cache_entry->data['#cache_redirect']), 'Render cache entry is not a redirect.'); + $this->assertFalse($is_redirecting_cache_item, 'Render cache entry is not a redirect.'); + // If this is a redirecting cache item unlike we expected, log it. + if ($is_redirecting_cache_item) { + debug($cache_entry->data); + } } else { // Verify that $cid contains a cache redirect. - $this->assertTrue(isset($cache_entry->data['#cache_redirect']), 'Render cache entry is a redirect.'); + $this->assertTrue($is_redirecting_cache_item, 'Render cache entry is a redirect.'); + // If this is not a redirecting cache item unlike we expected, log it. + if (!$is_redirecting_cache_item) { + debug($cache_entry->data); + } // Verify that the cache redirect points to the expected CID. $redirect_cache_metadata = $cache_entry->data['#cache']; $actual_redirection_cid = $this->createCacheId( diff --git a/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php b/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php index 6e87d2d..dd6b226 100644 --- a/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php +++ b/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php @@ -58,12 +58,12 @@ public function testEntityViewBuilderCache() { // Test that new entities (before they are saved for the first time) do not // generate a cache entry. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'full'); - $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags', 'contexts'], 'The render array element of new (unsaved) entities is not cached, but does have cache tags set.'); + $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags'], 'The render array element of new (unsaved) entities is not cached, but does have cache tags set.'); // Get a fully built entity view render array. $entity_test->save(); $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'full'); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(Cache::mergeContexts($build['#cache']['contexts'], ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']))); + $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme'])); $cid = implode(':', $cid_parts); $bin = $build['#cache']['bin']; @@ -113,7 +113,7 @@ public function testEntityViewBuilderCacheWithReferences() { // Get a fully built entity view render array for the referenced entity. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test_reference, 'full'); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(Cache::mergeContexts($build['#cache']['contexts'], ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']))); + $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme'])); $cid_reference = implode(':', $cid_parts); $bin_reference = $build['#cache']['bin']; @@ -132,7 +132,7 @@ public function testEntityViewBuilderCacheWithReferences() { // Get a fully built entity view render array. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'full'); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(Cache::mergeContexts($build['#cache']['contexts'], ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']))); + $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme'])); $cid = implode(':', $cid_parts); $bin = $build['#cache']['bin']; @@ -163,17 +163,17 @@ public function testEntityViewBuilderCacheToggling() { // Test a view mode in default conditions: render caching is enabled for // the entity type and the view mode. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'full'); - $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags', 'contexts', 'keys', 'bin'] , 'A view mode with render cache enabled has the correct output (cache tags, keys, contexts and bin).'); + $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags', 'keys', 'bin'] , 'A view mode with render cache enabled has the correct output (cache tags, keys, and bin).'); // Test that a view mode can opt out of render caching. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'test'); - $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags', 'contexts'], 'A view mode with render cache disabled has the correct output (only cache tags).'); + $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags'], 'A view mode with render cache disabled has the correct output (only cache tags).'); // Test that an entity type can opt out of render caching completely. $entity_test_no_cache = $this->createTestEntity('entity_test_label'); $entity_test_no_cache->save(); $build = $this->container->get('entity.manager')->getViewBuilder('entity_test_label')->view($entity_test_no_cache, 'full'); - $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags', 'contexts'], 'An entity type can opt out of render caching regardless of view mode configuration, but always has cache tags set.'); + $this->assertTrue(isset($build['#cache']) && array_keys($build['#cache']) == ['tags'], 'An entity type can opt out of render caching regardless of view mode configuration, but always has cache tags set.'); } /** diff --git a/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php b/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php index 299f811..0b6f430 100644 --- a/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php +++ b/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php @@ -32,7 +32,7 @@ public function testEntityUri() { $view_mode = $this->selectViewMode($entity_type); // The default cache contexts for rendered entities. - $entity_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.roles']; + $entity_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']; // Generate the standardized entity cache tags. $cache_tag = $this->entity->getCacheTags(); @@ -51,7 +51,11 @@ public function testEntityUri() { if (\Drupal::entityManager()->getDefinition($entity_type)->isRenderCacheable()) { $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode]; $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); - $redirected_cid = $this->createRedirectedCacheId($cache_keys, $entity_cache_contexts); + $redirected_cid = NULL; + $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->entity); + if (count($additional_cache_contexts)) { + $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts)); + } $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag, $this->getAdditionalCacheTagsForEntity($this->entity), array($render_cache_tag)); $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid); } diff --git a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/EntityReferenceTaxonomyTermRssFormatter.php b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/EntityReferenceTaxonomyTermRssFormatter.php index 614f165..8a5816a 100644 --- a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/EntityReferenceTaxonomyTermRssFormatter.php +++ b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/EntityReferenceTaxonomyTermRssFormatter.php @@ -32,7 +32,7 @@ public function viewElements(FieldItemListInterface $items) { $parent_entity = $items->getEntity(); $elements = array(); - foreach ($this->getEntitiesToView($items) as $delta => $entity) { + foreach ($this->getEntitiesToView($elements, $items) as $delta => $entity) { $parent_entity->rss_elements[] = array( 'key' => 'category', 'value' => $entity->label(), diff --git a/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php b/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php index 85028fd..96dfc8b 100644 --- a/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php +++ b/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php @@ -817,6 +817,37 @@ public function testAndOrCacheabilityPropagation(AccessResultInterface $first, $ } /** + * @covers ::orIf + * + * Tests the special case of ORing non-forbidden access results that are both + * cacheable but have different cacheability metadata. + * This is only the case for non-forbidden access results; we still abort the + * ORing process as soon as a forbidden access result is encountered. This is + * tested in ::testOrIf(). + */ + public function testOrIfCacheabilityMerging() { + $merge_both_directions = function(AccessResult $a, AccessResult $b) { + // A globally cacheable access result. + $a->setCacheable(TRUE); + // Another access result that is cacheable per permissions. + $b->setCacheable(TRUE)->cachePerPermissions(); + + $r1 = $a->orIf($b); + $this->assertTrue($r1->isCacheable()); + $this->assertSame(['user.permissions'], $r1->getCacheContexts()); + $r2 = $b->orIf($a); + $this->assertTrue($r2->isCacheable()); + $this->assertSame(['user.permissions'], $r2->getCacheContexts()); + }; + + // Merge either direction, get the same result. + $merge_both_directions(AccessResult::allowed(), AccessResult::allowed()); + $merge_both_directions(AccessResult::allowed(), AccessResult::neutral()); + $merge_both_directions(AccessResult::neutral(), AccessResult::neutral()); + $merge_both_directions(AccessResult::neutral(), AccessResult::allowed()); + } + + /** * Tests allowedIfHasPermissions(). * * @covers ::allowedIfHasPermissions