diff --git a/core/modules/content_translation/content_translation.module b/core/modules/content_translation/content_translation.module index 10f546dfd5..55251cc0ee 100644 --- a/core/modules/content_translation/content_translation.module +++ b/core/modules/content_translation/content_translation.module @@ -374,7 +374,8 @@ function content_translation_translate_access(EntityInterface $entity) { $account = \Drupal::currentUser(); $condition = $entity instanceof ContentEntityInterface && $entity->access('view') && !$entity->getUntranslated()->language()->isLocked() && \Drupal::languageManager()->isMultilingual() && $entity->isTranslatable() && - ($account->hasPermission('create content translations') || $account->hasPermission('update content translations') || $account->hasPermission('delete content translations')); + ($account->hasPermission('create content translations') || $account->hasPermission('update content translations') || $account->hasPermission('delete content translations') || + ($account->hasPermission('translate editable entities') && $entity->access('update'))); return AccessResult::allowedIf($condition)->cachePerPermissions()->addCacheableDependency($entity); } @@ -434,14 +435,23 @@ function content_translation_language_fallback_candidates_entity_view_alter(&$ca /** @var \Drupal\content_translation\ContentTranslationManagerInterface $manager */ $manager = \Drupal::service('content_translation.manager'); if ($manager->isEnabled($entity_type_id, $entity->bundle())) { - $entity_type = $entity->getEntityType(); - $permission = $entity_type->getPermissionGranularity() == 'bundle' ? $permission = "translate {$entity->bundle()} $entity_type_id" : "translate $entity_type_id"; - $current_user = \Drupal::currentuser(); - if (!$current_user->hasPermission('translate any entity') && !$current_user->hasPermission($permission)) { - foreach ($entity->getTranslationLanguages() as $langcode => $language) { - $metadata = $manager->getTranslationMetadata($entity->getTranslation($langcode)); - if (!$metadata->isPublished()) { - unset($candidates[$langcode]); + /* @var \Drupal\content_translation\ContentTranslationHandlerInterface $handler */ + $handler = \Drupal::entityTypeManager()->getHandler($entity->getEntityTypeId(), 'translation'); + foreach ($entity->getTranslationLanguages() as $langcode => $language) { + $metadata = $manager->getTranslationMetadata($entity->getTranslation($langcode)); + if (!$metadata->isPublished()) { + $access = $handler->getTranslationAccess($entity, 'update'); + $entity->addCacheableDependency($access); + if (!$access->isAllowed()) { + + // If the user has no translation update access, also check view + // access for that translation, to allow other modules to allow access + // to unpublished translations. + $access = $entity->getTranslation($langcode)->access('view', NULL, TRUE); + $entity->addCacheableDependency($access); + if (!$access->isAllowed()) { + unset($candidates[$langcode]); + } } } } diff --git a/core/modules/content_translation/content_translation.permissions.yml b/core/modules/content_translation/content_translation.permissions.yml index 17b5b3d7bd..c45cdf0274 100644 --- a/core/modules/content_translation/content_translation.permissions.yml +++ b/core/modules/content_translation/content_translation.permissions.yml @@ -8,6 +8,8 @@ delete content translations: title: 'Delete translations' translate any entity: title: 'Translate any entity' +translate editable entities: + title: 'Manage translations for any entity that the user can edit' permission_callbacks: - \Drupal\content_translation\ContentTranslationPermissions::contentPermissions diff --git a/core/modules/content_translation/src/ContentTranslationHandler.php b/core/modules/content_translation/src/ContentTranslationHandler.php index 08c3ab3f8a..91f8476bc5 100644 --- a/core/modules/content_translation/src/ContentTranslationHandler.php +++ b/core/modules/content_translation/src/ContentTranslationHandler.php @@ -291,7 +291,11 @@ public function getTranslationAccess(EntityInterface $entity, $op) { if (!$this->currentUser->hasPermission('translate any entity') && $permission_granularity = $entity_type->getPermissionGranularity()) { $translate_permission = $this->currentUser->hasPermission($permission_granularity == 'bundle' ? "translate {$entity->bundle()} {$entity->getEntityTypeId()}" : "translate {$entity->getEntityTypeId()}"); } - return AccessResult::allowedIf($translate_permission && $this->currentUser->hasPermission("$op content translations"))->cachePerPermissions(); + $access = AccessResult::allowedIf(($translate_permission && $this->currentUser->hasPermission("$op content translations")))->cachePerPermissions(); + if (!$access->isAllowed()) { + return AccessResult::allowedIfHasPermission($this->currentUser, 'translate editable entities')->andIf($entity->access('update', $this->currentUser, TRUE)); + } + return $access; } /** diff --git a/core/modules/content_translation/tests/src/Functional/ContentTranslationWorkflowsTest.php b/core/modules/content_translation/tests/src/Functional/ContentTranslationWorkflowsTest.php index dd778718b3..e404b316c3 100644 --- a/core/modules/content_translation/tests/src/Functional/ContentTranslationWorkflowsTest.php +++ b/core/modules/content_translation/tests/src/Functional/ContentTranslationWorkflowsTest.php @@ -5,6 +5,10 @@ use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; +use Drupal\entity_test\Entity\EntityTestMul; +use Drupal\entity_test\Entity\EntityTestMulRevPub; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; use Drupal\user\UserInterface; @@ -20,10 +24,36 @@ class ContentTranslationWorkflowsTest extends ContentTranslationTestBase { /** * The entity used for testing. * - * @var \Drupal\Core\Entity\EntityInterface + * @var \Drupal\entity_test\Entity\EntityTestMul */ protected $entity; + /** + * {@inheritdoc} + */ + protected $entityTypeId = 'entity_test_mulrevpub'; + + /** + * The referencing entity. + * + * @var \Drupal\entity_test\Entity\EntityTestMul + */ + protected $referencingEntity; + + /** + * The entity owner account to be used to test multilingual entity editing. + * + * @var \Drupal\user\UserInterface + */ + protected $entityOwner; + + /** + * The user that has entity owner permission but is not the owner. + * + * @var \Drupal\user\UserInterface + */ + protected $notEntityOwner; + /** * Modules to enable. * @@ -42,7 +72,59 @@ class ContentTranslationWorkflowsTest extends ContentTranslationTestBase { protected function setUp(): void { parent::setUp(); + + $field_storage = FieldStorageConfig::create([ + 'field_name' => 'field_reference', + 'type' => 'entity_reference', + 'entity_type' => $this->entityTypeId, + 'cardinality' => 1, + 'settings' => [ + 'target_type' => $this->entityTypeId, + ], + ]); + $field_storage->save(); + FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $this->entityTypeId, + 'label' => 'Reference', + 'translatable' => FALSE, + ])->save(); + + $this->container->get('entity_display.repository') + ->getViewDisplay($this->entityTypeId, $this->entityTypeId, 'default') + ->setComponent('field_reference', [ + 'type' => 'entity_reference_entity_view', + ]) + ->save(); + $this->setupEntity(); + + // Create a second entity that references the first to test how the + // translation can be viewed through an entity reference field. + $this->referencingEntity = EntityTestMulRevPub::create([ + 'name' => 'referencing', + 'field_reference' => $this->entity->id(), + ]); + $this->referencingEntity->addTranslation($this->langcodes[2], $this->referencingEntity->toArray()); + $this->referencingEntity->save(); + } + + /** + * {@inheritdoc} + */ + protected function setupUsers() { + $this->entityOwner = $this->drupalCreateUser($this->getEntityOwnerPermissions(), 'entity_owner'); + $this->notEntityOwner = $this->drupalCreateUser(); + $this->notEntityOwner->set('roles', $this->entityOwner->getRoles(TRUE)); + $this->notEntityOwner->save(); + parent::setupUsers(); + } + + /** + * Returns an array of permissions needed for the entity owner. + */ + protected function getEntityOwnerPermissions() { + return ['edit own entity_test content', 'translate editable entities', 'view test entity', 'view test entity translations', 'view unpublished test entity translations']; } /** @@ -51,6 +133,8 @@ protected function setUp(): void { protected function getTranslatorPermissions() { $permissions = parent::getTranslatorPermissions(); $permissions[] = 'view test entity'; + $permissions[] = 'view test entity translations'; + $permissions[] = 'view unpublished test entity translations'; return $permissions; } @@ -59,17 +143,20 @@ protected function getTranslatorPermissions() { * {@inheritdoc} */ protected function getEditorPermissions() { - return ['administer entity_test content']; + return ['administer entity_test content', 'view test entity', 'view test entity translations']; } /** * Creates a test entity and translate it. + * + * @param UserInterface|NULL $user + * (optional) The entity owner. */ - protected function setupEntity() { + protected function setupEntity(UserInterface $user = NULL) { $default_langcode = $this->langcodes[0]; // Create a test entity. - $user = $this->drupalCreateUser(); + $user = $user ?: $this->drupalCreateUser(); $values = [ 'name' => $this->randomMachineName(), 'user_id' => $user->id(), @@ -78,13 +165,19 @@ protected function setupEntity() { $id = $this->createEntity($values, $default_langcode); $storage = $this->container->get('entity_type.manager') ->getStorage($this->entityTypeId); + + // Create a translation that is not published to test view access. + $this->drupalLogin($this->translator); + $add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [$this->entityTypeId => $id, 'source' => $default_langcode, 'target' => $this->langcodes[2]]); + $edit = [ + 'name[0][value]' => 'translation name', + 'content_translation[status]' => FALSE + ]; + $this->drupalPostForm($add_translation_url, $edit, t('Save')); + $storage->resetCache([$id]); $this->entity = $storage->load($id); - // Create a translation. - $this->drupalLogin($this->translator); - $add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $this->langcodes[2]]); - $this->drupalPostForm($add_translation_url, [], t('Save')); $this->rebuildContainer(); } @@ -100,6 +193,8 @@ public function testWorkflows() { 'add_translation' => 403, 'edit_translation' => 403, 'delete_translation' => 403, + 'view_unpublished_translation' => 403, + 'view_unpublished_translation_reference' => FALSE, ]; $this->doTestWorkflows($this->editor, $expected_status); @@ -111,6 +206,8 @@ public function testWorkflows() { 'add_translation' => 200, 'edit_translation' => 200, 'delete_translation' => 200, + 'view_unpublished_translation' => 200, + 'view_unpublished_translation_reference' => TRUE, ]; $this->doTestWorkflows($this->translator, $expected_status); @@ -122,6 +219,8 @@ public function testWorkflows() { 'add_translation' => 200, 'edit_translation' => 403, 'delete_translation' => 403, + 'view_unpublished_translation' => 200, + 'view_unpublished_translation_reference' => TRUE, ]; $this->doTestWorkflows($this->administrator, $expected_status); @@ -153,6 +252,46 @@ public function testWorkflows() { } } } + + // Test workflows for the entity owner with non-editable content. + $expected_status = [ + 'edit' => 403, + 'delete' => 403, + 'overview' => 403, + 'add_translation' => 403, + 'edit_translation' => 403, + 'delete_translation' => 403, + 'view_unpublished_translation' => 200, + 'view_unpublished_translation_reference' => TRUE, + ]; + $this->doTestWorkflows($this->entityOwner, $expected_status); + + // Test workflows for the entity owner with editable content. + $this->setupEntity($this->entityOwner); + $this->referencingEntity->set('field_reference', $this->entity->id()); + $this->referencingEntity->save(); + $expected_status = [ + 'edit' => 200, + 'delete' => 403, + 'overview' => 200, + 'add_translation' => 200, + 'edit_translation' => 200, + 'delete_translation' => 200, + 'view_unpublished_translation' => 200, + 'view_unpublished_translation_reference' => TRUE, + ]; + $this->doTestWorkflows($this->entityOwner, $expected_status); + $expected_status = [ + 'edit' => 403, + 'delete' => 403, + 'overview' => 403, + 'add_translation' => 403, + 'edit_translation' => 403, + 'delete_translation' => 403, + 'view_unpublished_translation' => 200, + 'view_unpublished_translation_reference' => TRUE, + ]; + $this->doTestWorkflows($this->notEntityOwner, $expected_status); } /** @@ -212,7 +351,7 @@ protected function doTestWorkflows(UserInterface $user, $expected_status) { $editor = $expected_status['edit'] == 200; if ($editor) { - $this->clickLink('Edit', 2); + $this->clickLink('Edit', 1); // An editor should be pointed to the entity form in multilingual mode. // We need a new expected edit path with a new language. $expected_edit_path = $this->entity->toUrl('edit-form', $options)->toString(); @@ -231,16 +370,29 @@ protected function doTestWorkflows(UserInterface $user, $expected_status) { } $this->assertSession()->statusCodeEquals($expected_status['edit_translation']); + // When viewing an unpublished entity directly, access is currently denied + // completely. See https://www.drupal.org/node/2978048. + $this->drupalGet($this->entity->getTranslation($langcode)->toUrl()); + $this->assertSession()->statusCodeEquals($expected_status['view_unpublished_translation']); + + // On a reference field, the translation falls back to the default language. + $this->drupalGet($this->referencingEntity->getTranslation($langcode)->toUrl()); + $this->assertSession()->statusCodeEquals(200); + if ($expected_status['view_unpublished_translation_reference']) { + $this->assertSession()->pageTextContains('translation name'); + } + else { + $this->assertSession()->pageTextContains($this->entity->label()); + } + // Check whether the user is allowed to delete a translation. - $langcode = $this->langcodes[2]; - $options['language'] = $languages[$langcode]; $delete_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options); if ($expected_status['delete_translation'] == 200) { $this->drupalGet($translations_url); $editor = $expected_status['delete'] == 200; if ($editor) { - $this->clickLink('Delete', 2); + $this->clickLink('Delete', 1); // An editor should be pointed to the entity deletion form in // multilingual mode. We need a new expected delete path with a new // language. diff --git a/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml b/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml index abfb616fc2..62e4d82b74 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml +++ b/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml @@ -5,6 +5,8 @@ view test entity: title: 'View test entities' view test entity translations: title: 'View translations of test entities' +view unpublished test entity translations: + title: 'View unpublished translations of test entities' view test entity field: title: 'View test entity field' administer entity_test_with_bundle content: @@ -14,6 +16,8 @@ administer entity_test_bundle content: title: 'administer entity_test_bundle content' view all entity_test_query_access entities: title: 'view all entity_test_query_access entities' +edit own entity_test content: + title: 'Edit own entity_test content' permission_callbacks: - \Drupal\entity_test\EntityTestPermissions::entityTestBundlePermissions diff --git a/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php b/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php index 20766528b7..4e7b4dc2a0 100644 --- a/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php +++ b/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php @@ -5,6 +5,7 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityAccessControlHandler; +use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Session\AccountInterface; use Drupal\entity_test\Entity\EntityTestLabel; @@ -47,12 +48,21 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter } elseif (in_array($operation, ['view', 'view label'])) { if (!$entity->isDefaultTranslation()) { - return AccessResult::allowedIfHasPermission($account, 'view test entity translations'); + if ($entity instanceof EntityPublishedInterface && !$entity->isPublished()) { + return AccessResult::allowedIfHasPermission($account, 'view unpublished test entity translations'); + } + else { + return AccessResult::allowedIfHasPermission($account, 'view test entity translations'); + } } return AccessResult::allowedIfHasPermission($account, 'view test entity'); } elseif (in_array($operation, ['update', 'delete'])) { - return AccessResult::allowedIfHasPermission($account, 'administer entity_test content'); + $access = AccessResult::allowedIfHasPermission($account, 'administer entity_test content'); + if (!$access->isAllowed() && $operation === 'update' && $account->hasPermission('edit own entity_test content')) { + $access = $access->orIf(AccessResult::allowedIf($entity->getOwnerId() === $account->id()))->cachePerUser()->addCacheableDependency($entity); + } + return $access; } // No opinion.