diff --git a/core/modules/media_library/media_library.services.yml b/core/modules/media_library/media_library.services.yml index 1a35b2805d111788605ffa5277674142c4f2bdcc..b2d06643b533529df5bceb0ed838a2b4f8fd0aba 100644 --- a/core/modules/media_library/media_library.services.yml +++ b/core/modules/media_library/media_library.services.yml @@ -1,7 +1,7 @@ services: media_library.ui_builder: class: Drupal\media_library\MediaLibraryUiBuilder - arguments: ['@entity_type.manager', '@request_stack', '@views.executable', '@form_builder'] + arguments: ['@entity_type.manager', '@request_stack', '@views.executable', '@form_builder', '@media_library.opener_resolver'] media_library.route_subscriber: class: Drupal\media_library\Routing\RouteSubscriber tags: @@ -12,3 +12,4 @@ services: - [setContainer, ['@service_container']] media_library.opener.field_widget: class: Drupal\media_library\MediaLibraryFieldWidgetOpener + arguments: ['@entity_type.manager'] diff --git a/core/modules/media_library/src/MediaLibraryFieldWidgetOpener.php b/core/modules/media_library/src/MediaLibraryFieldWidgetOpener.php index 71ef10b2f265e69afe8d26558f967d12f8763328..b34b8d308b9ce346d89ad21eb909a6daefbdd086 100644 --- a/core/modules/media_library/src/MediaLibraryFieldWidgetOpener.php +++ b/core/modules/media_library/src/MediaLibraryFieldWidgetOpener.php @@ -2,8 +2,12 @@ namespace Drupal\media_library; +use Drupal\Core\Access\AccessResult; use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\InvokeCommand; +use Drupal\Core\Cache\RefinableCacheableDependencyInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Session\AccountInterface; /** @@ -11,11 +15,92 @@ */ class MediaLibraryFieldWidgetOpener implements MediaLibraryOpenerInterface { + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * MediaLibraryFieldWidgetOpener constructor. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + } + /** * {@inheritdoc} */ public function checkAccess(MediaLibraryState $state, AccountInterface $account) { - throw new \Exception('Not yet implemented, see https://www.drupal.org/project/drupal/issues/3038254.'); + $parameters = $state->getOpenerParameters() + ['entity_id' => NULL]; + + $process_result = function ($result) { + if ($result instanceof RefinableCacheableDependencyInterface) { + $result->addCacheContexts(['url.query_args']); + } + return $result; + }; + + // Forbid access if any of the required parameters are missing. + foreach (['entity_type_id', 'bundle', 'field_name'] as $key) { + if (empty($parameters[$key])) { + return $process_result(AccessResult::forbidden("$key parameter is missing.")); + } + } + + $entity_type_id = $parameters['entity_type_id']; + $bundle = $parameters['bundle']; + $field_name = $parameters['field_name']; + + // Since we defer to a field to determine access, ensure we are dealing with + // a fieldable entity type. + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + if (!$entity_type->entityClassImplements(FieldableEntityInterface::class)) { + throw new \LogicException("The media library can only be opened by fieldable entities."); + } + + $storage = $this->entityTypeManager->getStorage($entity_type_id); + $access_handler = $this->entityTypeManager->getAccessControlHandler($entity_type_id); + + if ($parameters['entity_id']) { + $entity = $storage->load($parameters['entity_id']); + $entity_access = $access_handler->access($entity, 'update', $account, TRUE); + } + else { + $entity_access = $access_handler->createAccess($bundle, $account, [], TRUE); + } + + // If entity-level access is denied, there's no point in continuing. + if (!$entity_access->isAllowed()) { + return $process_result($entity_access); + } + + // If the entity has not been loaded, create it in memory now. + if (!isset($entity)) { + $values = []; + if ($bundle_key = $entity_type->getKey('bundle')) { + $values[$bundle_key] = $bundle; + } + /** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */ + $entity = $storage->create($values); + } + + $items = $entity->get($field_name); + $field_definition = $items->getFieldDefinition(); + + if ($field_definition->getType() !== 'entity_reference') { + throw new \LogicException('Expected the media library to be opened by an entity reference field.'); + } + if ($field_definition->getFieldStorageDefinition()->getSetting('target_type') !== 'media') { + throw new \LogicException('Expected the media library to be opened by an entity reference field that target media items.'); + } + + $field_access = $access_handler->fieldAccess('edit', $field_definition, $account, $items, TRUE); + return $process_result($entity_access->andIf($field_access)); } /** diff --git a/core/modules/media_library/src/MediaLibraryUiBuilder.php b/core/modules/media_library/src/MediaLibraryUiBuilder.php index 56095b09a2b043587a09bbdae38d050fcb9ccd44..32262c962ddcb100684928dd6539233c48bd28b5 100644 --- a/core/modules/media_library/src/MediaLibraryUiBuilder.php +++ b/core/modules/media_library/src/MediaLibraryUiBuilder.php @@ -53,6 +53,13 @@ class MediaLibraryUiBuilder { */ protected $viewsExecutableFactory; + /** + * The media library opener resolver. + * + * @var \Drupal\media_library\OpenerResolverInterface + */ + protected $openerResolver; + /** * Constructs a MediaLibraryUiBuilder instance. * @@ -64,12 +71,19 @@ class MediaLibraryUiBuilder { * The views executable factory. * @param \Drupal\Core\Form\FormBuilderInterface $form_builder * The currently active request object. + * @param \Drupal\media_library\OpenerResolverInterface $opener_resolver + * The opener resolver. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, RequestStack $request_stack, ViewExecutableFactory $views_executable_factory, FormBuilderInterface $form_builder) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, RequestStack $request_stack, ViewExecutableFactory $views_executable_factory, FormBuilderInterface $form_builder, OpenerResolverInterface $opener_resolver = NULL) { $this->entityTypeManager = $entity_type_manager; $this->request = $request_stack->getCurrentRequest(); $this->viewsExecutableFactory = $views_executable_factory; $this->formBuilder = $form_builder; + if (!$opener_resolver) { + @trigger_error('The media_library.opener_resolver service must be passed to ' . __METHOD__ . ' and will be required before Drupal 9.0.0.', E_USER_DEPRECATED); + $opener_resolver = \Drupal::service('media_library.opener_resolver'); + } + $this->openerResolver = $opener_resolver; } /** @@ -159,7 +173,7 @@ protected function buildLibraryContent(MediaLibraryState $state) { * Check access to the media library. * * @param \Drupal\Core\Session\AccountInterface $account - * (optional) Run access checks for this account. + * Run access checks for this account. * @param \Drupal\media_library\MediaLibraryState $state * (optional) The current state of the media library, derived from the * current request. @@ -167,10 +181,10 @@ protected function buildLibraryContent(MediaLibraryState $state) { * @return \Drupal\Core\Access\AccessResult * The access result. */ - public function checkAccess(AccountInterface $account = NULL, MediaLibraryState $state = NULL) { + public function checkAccess(AccountInterface $account, MediaLibraryState $state = NULL) { if (!$state) { try { - MediaLibraryState::fromRequest($this->request); + $state = MediaLibraryState::fromRequest($this->request); } catch (BadRequestHttpException $e) { return AccessResult::forbidden($e->getMessage()); @@ -189,8 +203,16 @@ public function checkAccess(AccountInterface $account = NULL, MediaLibraryState return AccessResult::forbidden('The media library widget display does not exist.') ->addCacheableDependency($view); } - return AccessResult::allowedIfHasPermission($account, 'view media') + + // The user must at least be able to view media in order to access the media + // library. + $can_view_media = AccessResult::allowedIfHasPermission($account, 'view media') ->addCacheableDependency($view); + + // Delegate any further access checking to the opener service nominated by + // the media library state. + return $this->openerResolver->get($state)->checkAccess($state, $account) + ->andIf($can_view_media); } /** diff --git a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php index e612c3e4f47767f3d3f37f70d8fda03da772ec53..419cf66174471211f477e45137d931c43fc46e57 100644 --- a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php +++ b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php @@ -461,9 +461,17 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen // This particular media library opener needs some extra metadata for its // \Drupal\media_library\MediaLibraryOpenerInterface::getSelectionResponse() // to be able to target the element whose 'data-media-library-widget-value' - // attribute is the same as $field_widget_id. + // attribute is the same as $field_widget_id. The entity ID, entity type ID, + // bundle, field name are used for access checking. + $entity = $items->getEntity(); $state = MediaLibraryState::create('media_library.opener.field_widget', $allowed_media_type_ids, $selected_type_id, $remaining, [ 'field_widget_id' => $field_widget_id, + 'entity_type_id' => $entity->getEntityTypeId(), + 'bundle' => $entity->bundle(), + 'field_name' => $field_name, + // The entity ID needs to be a string to ensure that the media library + // state generates its tamper-proof hash in a consistent way. + 'entity_id' => (string) $entity->id(), ]); // Add a button that will load the Media library in a modal using AJAX. diff --git a/core/modules/media_library/tests/modules/media_library_test/media_library_test.module b/core/modules/media_library/tests/modules/media_library_test/media_library_test.module new file mode 100644 index 0000000000000000000000000000000000000000..3d9576bc1a496d632345303ee4d0989cc1e2b125 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/media_library_test.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * Contains hook implementations for the media_library_test module. + */ + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Session\AccountInterface; + +/** + * Implements hook_entity_field_access(). + */ +function media_library_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) { + return AccessResult::forbiddenIf($field_definition->getName() === 'field_media_no_access', 'Field access denied by test module'); +} diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php index 999a1f552814f86e32259250a22c440cdcc4acef..cbefde5417d9ec2380c32104710c4e10fa5ae0d4 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php @@ -332,7 +332,13 @@ public function testWidgetAccess() { // Create a working state. $allowed_types = ['type_one', 'type_two', 'type_three', 'type_four']; - $state = MediaLibraryState::create('test', $allowed_types, 'type_three', 2); + // The opener parameters are not relevant to the test, but the opener + // expects them to be there or it will deny access. + $state = MediaLibraryState::create('media_library.opener.field_widget', $allowed_types, 'type_three', 2, [ + 'entity_type_id' => 'node', + 'bundle' => 'basic_page', + 'field_name' => 'field_unlimited_media', + ]); $url_options = ['query' => $state->all()]; // Verify that unprivileged users can't access the widget view. @@ -344,8 +350,10 @@ public function testWidgetAccess() { $assert_session->responseContains('Access denied'); // Allow users with 'view media' permission to access the media library view - // and controller. + // and controller. Since we are using the node entity type in the state + // object, ensure the user also has permission to work with those. $this->grantPermissions($role, [ + 'create basic_page content', 'view media', ]); $this->drupalGet('admin/content/media-widget', $url_options); diff --git a/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php b/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php index 2b83916e56b4f466487ea047878b28fccd8df510..75d064a5e9981cf327cca7eefc6b1f01444ea6c1 100644 --- a/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php +++ b/core/modules/media_library/tests/src/Kernel/MediaLibraryAccessTest.php @@ -2,6 +2,12 @@ namespace Drupal\Tests\media_library\Kernel; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultReasonInterface; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\entity_test\Entity\EntityTestBundle; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; use Drupal\image\Entity\ImageStyle; use Drupal\KernelTests\KernelTestBase; use Drupal\media_library\MediaLibraryState; @@ -21,8 +27,10 @@ class MediaLibraryAccessTest extends KernelTestBase { * {@inheritdoc} */ protected static $modules = [ + 'entity_test', 'media', 'media_library', + 'media_library_test', 'file', 'field', 'image', @@ -41,6 +49,7 @@ protected function setUp() { $this->installEntitySchema('file'); $this->installSchema('file', 'file_usage'); $this->installSchema('system', ['sequences', 'key_value_expire']); + $this->installEntitySchema('entity_test'); $this->installEntitySchema('media'); $this->installConfig([ 'field', @@ -51,6 +60,23 @@ protected function setUp() { 'media_library', ]); + EntityTestBundle::create(['id' => 'test'])->save(); + + $field_storage = FieldStorageConfig::create([ + 'type' => 'entity_reference', + 'field_name' => 'field_test_media', + 'entity_type' => 'entity_test', + 'settings' => [ + 'target_type' => 'media', + ], + ]); + $field_storage->save(); + + FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'test', + ])->save(); + // Create an account with special UID 1. $this->createUser([]); } @@ -72,29 +98,180 @@ public function testMediaLibraryImageStyleAccess() { } /** - * Tests the Media Library access. + * Tests that the field widget opener respects entity creation permissions. */ - public function testMediaLibraryAccess() { + public function testFieldWidgetEntityCreateAccess() { /** @var \Drupal\media_library\MediaLibraryUiBuilder $ui_builder */ $ui_builder = $this->container->get('media_library.ui_builder'); // Create a media library state to test access. - $state = MediaLibraryState::create('test', ['file', 'image'], 'file', 2); + $state = MediaLibraryState::create('media_library.opener.field_widget', ['file', 'image'], 'file', 2, [ + 'entity_type_id' => 'entity_test', + 'bundle' => 'test', + 'field_name' => 'field_test_media', + ]); + + $access_result = $ui_builder->checkAccess($this->createUser(), $state); + $this->assertAccess($access_result, FALSE, "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create test entity_test_with_bundle entities'.", [], ['url.query_args', 'user.permissions']); + + // Create a user with the appropriate permissions and assert that access is + // granted. + $account = $this->createUser([ + 'create test entity_test_with_bundle entities', + 'view media', + ]); + $access_result = $ui_builder->checkAccess($account, $state); + $this->assertAccess($access_result, TRUE, NULL, Views::getView('media_library')->storage->getCacheTags(), ['url.query_args', 'user.permissions']); + } + + /** + * Tests that the field widget opener respects entity-specific access. + */ + public function testFieldWidgetEntityEditAccess() { + /** @var \Drupal\media_library\MediaLibraryUiBuilder $ui_builder */ + $ui_builder = $this->container->get('media_library.ui_builder'); + + $forbidden_entity = EntityTest::create([ + 'type' => 'test', + // This label will automatically cause an access denial. + // @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess() + 'name' => 'forbid_access', + ]); + $forbidden_entity->save(); + + // Create a media library state to test access. + $state = MediaLibraryState::create('media_library.opener.field_widget', ['file', 'image'], 'file', 2, [ + 'entity_type_id' => $forbidden_entity->getEntityTypeId(), + 'bundle' => $forbidden_entity->bundle(), + 'field_name' => 'field_test_media', + 'entity_id' => $forbidden_entity->id(), + ]); + + $access_result = $ui_builder->checkAccess($this->createUser(), $state); + $this->assertAccess($access_result, FALSE, NULL, [], ['url.query_args']); + + $neutral_entity = EntityTest::create([ + 'type' => 'test', + // This label will result in neutral access. + // @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess() + 'name' => $this->randomString(), + ]); + $neutral_entity->save(); + + $parameters = $state->getOpenerParameters(); + $parameters['entity_id'] = $neutral_entity->id(); + $state = MediaLibraryState::create( + $state->getOpenerId(), + $state->getAllowedTypeIds(), + $state->getSelectedTypeId(), + $state->getAvailableSlots(), + $parameters + ); + + $access_result = $ui_builder->checkAccess($this->createUser(), $state); + $this->assertTrue($access_result->isNeutral()); + $this->assertAccess($access_result, FALSE, NULL, [], ['url.query_args', 'user.permissions']); + + // Give the user permission to edit the entity and assert that access is + // granted. + $account = $this->createUser([ + 'administer entity_test content', + 'view media', + ]); + $access_result = $ui_builder->checkAccess($account, $state); + $this->assertAccess($access_result, TRUE, NULL, Views::getView('media_library')->storage->getCacheTags(), ['url.query_args', 'user.permissions']); + } + + /** + * Tests that the field widget opener respects entity field-level access. + */ + public function testFieldWidgetEntityFieldAccess() { + $field_storage = FieldStorageConfig::create([ + 'type' => 'entity_reference', + 'entity_type' => 'entity_test', + // The media_library_test module will deny access to this field. + // @see media_library_test_entity_field_access() + 'field_name' => 'field_media_no_access', + 'settings' => [ + 'target_type' => 'media', + ], + ]); + $field_storage->save(); + + FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'test', + ])->save(); + + /** @var \Drupal\media_library\MediaLibraryUiBuilder $ui_builder */ + $ui_builder = $this->container->get('media_library.ui_builder'); + + // Create an account with administrative access to the test entity type, + // so that we can be certain that field access is checked. + $account = $this->createUser(['administer entity_test content']); + + // Test that access is denied even without an entity to work with. + $state = MediaLibraryState::create('media_library.opener.field_widget', ['file', 'image'], 'file', 2, [ + 'entity_type_id' => 'entity_test', + 'bundle' => 'test', + 'field_name' => $field_storage->getName(), + ]); + $access_result = $ui_builder->checkAccess($account, $state); + $this->assertAccess($access_result, FALSE, 'Field access denied by test module', [], ['url.query_args', 'user.permissions']); + + // Assert that field access is also checked with a real entity. + $entity = EntityTest::create([ + 'type' => 'test', + 'name' => $this->randomString(), + ]); + $entity->save(); + + $parameters = $state->getOpenerParameters(); + $parameters['entity_id'] = $entity->id(); + + $state = MediaLibraryState::create( + $state->getOpenerId(), + $state->getAllowedTypeIds(), + $state->getSelectedTypeId(), + $state->getAvailableSlots(), + $parameters + ); + $access_result = $ui_builder->checkAccess($account, $state); + $this->assertAccess($access_result, FALSE, 'Field access denied by test module', [], ['url.query_args', 'user.permissions']); + } + + /** + * Tests that media library access respects the media_library view. + */ + public function testViewAccess() { + /** @var \Drupal\media_library\MediaLibraryUiBuilder $ui_builder */ + $ui_builder = $this->container->get('media_library.ui_builder'); + + // Create a media library state to test access. + $state = MediaLibraryState::create('media_library.opener.field_widget', ['file', 'image'], 'file', 2, [ + 'entity_type_id' => 'entity_test', + 'bundle' => 'test', + 'field_name' => 'field_test_media', + ]); // Create a clone of the view so we can reset the original later. $view_original = clone Views::getView('media_library'); - // Create our test users. - $forbidden_account = $this->createUser([]); - $allowed_account = $this->createUser(['view media']); + // Create our test users. Both have permission to create entity_test content + // so that we can specifically test Views-related access checking. + // @see ::testEntityCreateAccess() + $forbidden_account = $this->createUser([ + 'create test entity_test_with_bundle entities', + ]); + $allowed_account = $this->createUser([ + 'create test entity_test_with_bundle entities', + 'view media', + ]); // Assert the 'view media' permission is needed to access the library and // validate the cache dependencies. $access_result = $ui_builder->checkAccess($forbidden_account, $state); - $this->assertFalse($access_result->isAllowed()); - $this->assertSame("The 'view media' permission is required.", $access_result->getReason()); - $this->assertSame($view_original->storage->getCacheTags(), $access_result->getCacheTags()); - $this->assertSame(['user.permissions'], $access_result->getCacheContexts()); + $this->assertAccess($access_result, FALSE, "The 'view media' permission is required.", $view_original->storage->getCacheTags(), ['url.query_args', 'user.permissions']); // Assert that the media library access is denied when the view widget // display is deleted. @@ -104,27 +281,42 @@ public function testMediaLibraryAccess() { $view_storage->set('display', $displays); $view_storage->save(); $access_result = $ui_builder->checkAccess($allowed_account, $state); - $this->assertFalse($access_result->isAllowed()); - $this->assertSame('The media library widget display does not exist.', $access_result->getReason()); - $this->assertSame($view_original->storage->getCacheTags(), $access_result->getCacheTags()); - $this->assertSame([], $access_result->getCacheContexts()); + $this->assertAccess($access_result, FALSE, 'The media library widget display does not exist.', $view_original->storage->getCacheTags()); // Restore the original view and assert that the media library controller // works again. $view_original->storage->save(); $access_result = $ui_builder->checkAccess($allowed_account, $state); - $this->assertTrue($access_result->isAllowed()); - $this->assertSame($view_original->storage->getCacheTags(), $access_result->getCacheTags()); - $this->assertSame(['user.permissions'], $access_result->getCacheContexts()); + $this->assertAccess($access_result, TRUE, NULL, $view_original->storage->getCacheTags(), ['url.query_args', 'user.permissions']); // Assert that the media library access is denied when the entire media // library view is deleted. Views::getView('media_library')->storage->delete(); $access_result = $ui_builder->checkAccess($allowed_account, $state); - $this->assertFalse($access_result->isAllowed()); - $this->assertSame('The media library view does not exist.', $access_result->getReason()); - $this->assertSame([], $access_result->getCacheTags()); - $this->assertSame([], $access_result->getCacheContexts()); + $this->assertAccess($access_result, FALSE, 'The media library view does not exist.'); + } + + /** + * Asserts various aspects of an access result. + * + * @param \Drupal\Core\Access\AccessResult $access_result + * The access result. + * @param bool $is_allowed + * The expected access status. + * @param string $expected_reason + * (optional) The expected reason attached to the access result. + * @param string[] $expected_cache_tags + * (optional) The expected cache tags attached to the access result. + * @param string[] $expected_cache_contexts + * (optional) The expected cache contexts attached to the access result. + */ + private function assertAccess(AccessResult $access_result, $is_allowed, $expected_reason = NULL, array $expected_cache_tags = [], array $expected_cache_contexts = []) { + $this->assertSame($is_allowed, $access_result->isAllowed()); + if ($access_result instanceof AccessResultReasonInterface && isset($expected_reason)) { + $this->assertSame($expected_reason, $access_result->getReason()); + } + $this->assertSame($expected_cache_tags, $access_result->getCacheTags()); + $this->assertSame($expected_cache_contexts, $access_result->getCacheContexts()); } }