diff --git a/core/lib/Drupal/Core/Database/database.api.php b/core/lib/Drupal/Core/Database/database.api.php index 82b6408669ae269846978bed5b5c35960f99ea1a..a7ccdc32a08a9b1a2574dd3bc6ebcd6c42897f30 100644 --- a/core/lib/Drupal/Core/Database/database.api.php +++ b/core/lib/Drupal/Core/Database/database.api.php @@ -5,7 +5,7 @@ * Hooks related to the Database system and the Schema API. */ -use Drupal\Core\Database\Query\Condition; +use Drupal\Core\Database\Query\SelectInterface; /** * @defgroup database Database abstraction layer @@ -425,6 +425,12 @@ function hook_query_alter(Drupal\Core\Database\Query\AlterableInterface $query) /** * Perform alterations to a structured query for a given tag. * + * Some common tags include: + * - 'entity_reference': For queries that return entities that may be referenced + * by an entity reference field. + * - ENTITY_TYPE . '_access': For queries of entities that will be displayed in + * a listing (e.g., from Views) and therefore require access control. + * * @param $query * An Query object describing the composite parts of a SQL query. * @@ -436,34 +442,40 @@ function hook_query_alter(Drupal\Core\Database\Query\AlterableInterface $query) * @ingroup database */ function hook_query_TAG_alter(Drupal\Core\Database\Query\AlterableInterface $query) { - // Skip the extra expensive alterations if site has no node access control modules. - if (!node_access_view_all_nodes()) { - // Prevent duplicates records. - $query->distinct(); - // The recognized operations are 'view', 'update', 'delete'. - if (!$op = $query->getMetaData('op')) { - $op = 'view'; - } - // Skip the extra joins and conditions for node admins. - if (!\Drupal::currentUser()->hasPermission('bypass node access')) { - // The node_access table has the access grants for any given node. - $access_alias = $query->join('node_access', 'na', '%alias.nid = n.nid'); - $or = new Condition('OR'); - // If any grant exists for the specified user, then user has access to the node for the specified operation. - foreach (node_access_grants($op, $query->getMetaData('account')) as $realm => $gids) { - foreach ($gids as $gid) { - $or->condition((new Condition('AND')) - ->condition($access_alias . '.gid', $gid) - ->condition($access_alias . '.realm', $realm) - ); - } - } + // This is an example of a possible hook_query_media_access_alter() + // implementation. In other words, alter queries of media entities that + // require access control (have the 'media_access' query tag). - if (count($or->conditions())) { - $query->condition($or); - } + // Determine which media entities we want to remove from the query. In this + // example, we hard-code some media IDs. + $media_entities_to_hide = [1, 3]; + + // In this example, we're only interested in applying our media access + // restrictions to SELECT queries. hook_media_access() can be used to apply + // access control to 'update' and 'delete' operations. + if (!($query instanceof SelectInterface)) { + return; + } - $query->condition($access_alias . 'grant_' . $op, 1, '>='); + // The tables in the query. This can include media entity tables and other + // tables. Tables might be joined more than once, with aliases. + $query_tables = $query->getTables(); + + // The tables belonging to media entity storage. + $table_mapping = \Drupal::entityTypeManager()->getStorage('media')->getTableMapping(); + $media_tables = $table_mapping->getTableNames(); + + // For each table in the query, if it's a media entity storage table, add a + // condition to filter out records belonging to a media entity that we wish + // to hide. + foreach ($query_tables as $alias => $info) { + // Skip over subqueries. + if ($info['table'] instanceof SelectInterface) { + continue; + } + $real_table_name = $info['table']; + if (in_array($real_table_name, $media_tables)) { + $query->condition("$alias.mid", $media_entities_to_hide, 'NOT IN'); } } } diff --git a/core/lib/Drupal/Core/Entity/entity.api.php b/core/lib/Drupal/Core/Entity/entity.api.php index f557458d380193e1ca3cab9a13dbed565d9f88a2..d2ffcc4caeb0ad9691a31dc0ba583b550390d2dd 100644 --- a/core/lib/Drupal/Core/Entity/entity.api.php +++ b/core/lib/Drupal/Core/Entity/entity.api.php @@ -634,6 +634,10 @@ /** * Control entity operation access. * + * Note that this hook is not called for listings (e.g., from entity queries + * and Views). For nodes, see @link node_access Node access rights @endlink for + * a full explanation. For other entity types, see hook_query_TAG_alter(). + * * @param \Drupal\Core\Entity\EntityInterface $entity * The entity to check access to. * @param string $operation @@ -654,6 +658,7 @@ * @see \Drupal\Core\Entity\EntityAccessControlHandler * @see hook_entity_create_access() * @see hook_ENTITY_TYPE_access() + * @see hook_query_TAG_alter() * * @ingroup entity_api */ @@ -665,6 +670,10 @@ function hook_entity_access(\Drupal\Core\Entity\EntityInterface $entity, $operat /** * Control entity operation access for a specific entity type. * + * Note that this hook is not called for listings (e.g., from entity queries + * and Views). For nodes, see @link node_access Node access rights @endlink for + * a full explanation. For other entity types, see hook_query_TAG_alter(). + * * @param \Drupal\Core\Entity\EntityInterface $entity * The entity to check access to. * @param string $operation @@ -678,6 +687,7 @@ function hook_entity_access(\Drupal\Core\Entity\EntityInterface $entity, $operat * @see \Drupal\Core\Entity\EntityAccessControlHandler * @see hook_ENTITY_TYPE_create_access() * @see hook_entity_access() + * @see hook_query_TAG_alter() * * @ingroup entity_api */ diff --git a/core/modules/views/src/EntityViewsData.php b/core/modules/views/src/EntityViewsData.php index cf886f4caf895b971ead4b71e7e52ced341c38b4..f8d8ed6301707009d33f3bde12a53a208459e5ec 100644 --- a/core/modules/views/src/EntityViewsData.php +++ b/core/modules/views/src/EntityViewsData.php @@ -178,6 +178,7 @@ public function getViewsData() { 'field' => $base_field, 'title' => $this->entityType->getLabel(), 'cache_contexts' => $this->entityType->getListCacheContexts(), + 'access query tag' => $this->entityType->id() . '_access', ]; $data[$base_table]['table']['entity revision'] = FALSE; diff --git a/core/modules/views/tests/modules/views_test_query_access/views_test_query_access.info.yml b/core/modules/views/tests/modules/views_test_query_access/views_test_query_access.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..eb54049ccad1e228ba5e4557e067a1aa918ddb6d --- /dev/null +++ b/core/modules/views/tests/modules/views_test_query_access/views_test_query_access.info.yml @@ -0,0 +1,8 @@ +name: 'Views test query access' +type: module +description: 'Module to test entity query access in Views.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - drupal:views diff --git a/core/modules/views/tests/modules/views_test_query_access/views_test_query_access.module b/core/modules/views/tests/modules/views_test_query_access/views_test_query_access.module new file mode 100644 index 0000000000000000000000000000000000000000..b120c83ea9297cfa29ba13612040ddf43a229084 --- /dev/null +++ b/core/modules/views/tests/modules/views_test_query_access/views_test_query_access.module @@ -0,0 +1,70 @@ +<?php + +/** + * @file + * Module to test entity query access in Views. + */ + +use Drupal\Core\Database\Query\AlterableInterface; +use Drupal\Core\Database\Query\SelectInterface; +use Drupal\Core\Entity\Sql\SqlEntityStorageInterface; +use Drupal\Core\Entity\Sql\DefaultTableMapping; + +/** + * Implements hook_query_TAG_alter() for the 'media_access' query tag. + */ +function views_test_query_access_query_media_access_alter(AlterableInterface $query) { + _views_test_query_access_restrict_by_uuid($query); +} + +/** + * Implements hook_query_TAG_alter() for the 'block_content_access' query tag. + */ +function views_test_query_access_query_block_content_access_alter(AlterableInterface $query) { + _views_test_query_access_restrict_by_uuid($query); +} + +/** + * Excludes entities with the 'hidden-ENTITY_TYPE_ID' UUID from a query. + * + * @param \Drupal\Core\Database\Query\AlterableInterface $query + * The Views select query to alter. + */ +function _views_test_query_access_restrict_by_uuid(AlterableInterface $query) { + if (!($query instanceof SelectInterface)) { + return; + } + + /** @var \Drupal\views\ViewExecutable $view */ + $view = $query->getMetaData('view'); + $entity_type = $view->getBaseEntityType(); + + $storage = \Drupal::entityTypeManager()->getStorage($entity_type->id()); + if (!($storage instanceof SqlEntityStorageInterface)) { + return; + } + + $table_mapping = $storage->getTableMapping(); + if (!($table_mapping instanceof DefaultTableMapping)) { + return; + } + + $base_table = $table_mapping->getBaseTable(); + $data_table = $table_mapping->getDataTable(); + + // We are excluding entities by UUID, which means we need to be certain the + // base table is joined in the query. + $tables = $query->getTables(); + if (isset($tables[$data_table]) && !isset($tables[$base_table])) { + $data_table_alias = $tables[$data_table]['alias']; + $id_key = $entity_type->getKey('id'); + $base_table = $query->innerJoin($base_table, NULL, "$data_table_alias.$id_key = $base_table.$id_key"); + } + + // Figure out the column name of the UUID field and add a condition on that. + $base_field_definitions = \Drupal::service('entity_field.manager') + ->getBaseFieldDefinitions($entity_type->id()); + $uuid_key = $entity_type->getKey('uuid'); + $uuid_column_name = $table_mapping->getFieldColumnName($base_field_definitions[$uuid_key], NULL); + $query->condition("$base_table.$uuid_column_name", 'hidden-' . $entity_type->id(), '<>'); +} diff --git a/core/modules/views/tests/src/Functional/Entity/EntityQueryAccessTest.php b/core/modules/views/tests/src/Functional/Entity/EntityQueryAccessTest.php new file mode 100644 index 0000000000000000000000000000000000000000..73a7f655109d5c277a677efa376aa2cdf842dfb4 --- /dev/null +++ b/core/modules/views/tests/src/Functional/Entity/EntityQueryAccessTest.php @@ -0,0 +1,108 @@ +<?php + +namespace Drupal\Tests\views\Functional\Entity; + +use Drupal\block_content\Entity\BlockContent; +use Drupal\block_content\Entity\BlockContentType; +use Drupal\media\Entity\Media; +use Drupal\Tests\media\Traits\MediaTypeCreationTrait; +use Drupal\Tests\views\Functional\ViewTestBase; + +/** + * Tests that Views respects 'ENTITY_TYPE_access' query tags. + * + * @group views + */ +class EntityQueryAccessTest extends ViewTestBase { + + use MediaTypeCreationTrait; + + /** + * {@inheritdoc} + */ + public static $modules = [ + 'media_test_source', + 'views_test_query_access', + ]; + + /** + * Tests that the 'media_access' query tag is respected by Views. + */ + public function testMediaEntityQueryAccess() { + $this->container->get('module_installer')->install(['media']); + + $media_type = $this->createMediaType('test'); + $source_field = $media_type->getSource() + ->getSourceFieldDefinition($media_type) + ->getName(); + + $hidden_media = Media::create([ + 'bundle' => $media_type->id(), + // This UUID should prevent this media item from being visible in the + // view. + // @see views_test_access_query_media_access_alter() + 'uuid' => 'hidden-media', + 'name' => $this->randomString(), + $source_field => $this->randomString(), + ]); + $hidden_media->save(); + + $accessible_media = Media::create([ + 'bundle' => $media_type->id(), + 'name' => $this->randomString(), + $source_field => $this->randomString(), + ]); + $accessible_media->save(); + + $account = $this->drupalCreateUser([ + 'access media overview', + 'administer media', + ]); + $this->drupalLogin($account); + + $this->drupalGet('/admin/content/media'); + $assert_session = $this->assertSession(); + $assert_session->statusCodeEquals(200); + $assert_session->linkExists($accessible_media->label()); + $assert_session->linkNotExists($hidden_media->label()); + } + + /** + * Tests that the 'block_content_access' query tag is respected by Views. + */ + public function testBlockContentEntityQueryAccess() { + $this->container->get('module_installer')->install(['block_content']); + + BlockContentType::create([ + 'id' => 'test', + 'label' => 'Test', + ])->save(); + + $hidden_block = BlockContent::create([ + 'type' => 'test', + // This UUID should prevent this block from being visible in the view. + // @see views_test_access_query_block_content_access_alter() + 'uuid' => 'hidden-block_content', + 'info' => $this->randomString(), + ]); + $hidden_block->save(); + + $accessible_block = BlockContent::create([ + 'type' => 'test', + 'info' => $this->randomString(), + ]); + $accessible_block->save(); + + $account = $this->drupalCreateUser([ + 'administer blocks', + ]); + $this->drupalLogin($account); + + $this->drupalGet('/admin/structure/block/block-content'); + $assert_session = $this->assertSession(); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains($accessible_block->label()); + $assert_session->pageTextNotContains($hidden_block->label()); + } + +}