diff --git a/core/lib/Drupal/Core/Annotation/Action.php b/core/lib/Drupal/Core/Annotation/Action.php index 0464ed2..f91fdf8 100644 --- a/core/lib/Drupal/Core/Annotation/Action.php +++ b/core/lib/Drupal/Core/Annotation/Action.php @@ -54,4 +54,16 @@ class Action extends Plugin { */ public $type = ''; + /** + * A machine-readable of operations this action performs on the entities. + * + * This can be: + * - view + * - update + * - create + * - delete + * + * @var array + */ + public $operations = array(); } diff --git a/core/modules/comment/lib/Drupal/comment/Plugin/Action/PublishComment.php b/core/modules/comment/lib/Drupal/comment/Plugin/Action/PublishComment.php index 41ed873..b21c9b2 100644 --- a/core/modules/comment/lib/Drupal/comment/Plugin/Action/PublishComment.php +++ b/core/modules/comment/lib/Drupal/comment/Plugin/Action/PublishComment.php @@ -16,7 +16,10 @@ * @Action( * id = "comment_publish_action", * label = @Translation("Publish comment"), - * type = "comment" + * type = "comment", + * operations = { + * "update" + * } * ) */ class PublishComment extends ActionBase { diff --git a/core/modules/comment/lib/Drupal/comment/Plugin/Action/SaveComment.php b/core/modules/comment/lib/Drupal/comment/Plugin/Action/SaveComment.php index 8cb02be..4ce4320 100644 --- a/core/modules/comment/lib/Drupal/comment/Plugin/Action/SaveComment.php +++ b/core/modules/comment/lib/Drupal/comment/Plugin/Action/SaveComment.php @@ -16,7 +16,10 @@ * @Action( * id = "comment_save_action", * label = @Translation("Save comment"), - * type = "comment" + * type = "comment", + * operations = { + * "update" + * } * ) */ class SaveComment extends ActionBase { diff --git a/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishByKeywordComment.php b/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishByKeywordComment.php index 92d4014..549d93e 100644 --- a/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishByKeywordComment.php +++ b/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishByKeywordComment.php @@ -16,7 +16,10 @@ * @Action( * id = "comment_unpublish_by_keyword_action", * label = @Translation("Unpublish comment containing keyword(s)"), - * type = "comment" + * type = "comment", + * operations = { + * "update" + * } * ) */ class UnpublishByKeywordComment extends ConfigurableActionBase { diff --git a/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishComment.php b/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishComment.php index 74d565a..d4f8006 100644 --- a/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishComment.php +++ b/core/modules/comment/lib/Drupal/comment/Plugin/Action/UnpublishComment.php @@ -16,7 +16,10 @@ * @Action( * id = "comment_unpublish_action", * label = @Translation("Unpublish comment"), - * type = "comment" + * type = "comment", + * operations = { + * "update" + * } * ) */ class UnpublishComment extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/AssignOwnerNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/AssignOwnerNode.php index 2de926c..800fff2 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/AssignOwnerNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/AssignOwnerNode.php @@ -18,7 +18,10 @@ * @Action( * id = "node_assign_owner_action", * label = @Translation("Change the author of content"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class AssignOwnerNode extends ConfigurableActionBase implements ContainerFactoryPluginInterface { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/DeleteNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/DeleteNode.php index 603f097..13a6693 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/DeleteNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/DeleteNode.php @@ -19,7 +19,10 @@ * id = "node_delete_action", * label = @Translation("Delete selected content"), * type = "node", - * confirm_form_path = "admin/content/node/delete" + * confirm_form_path = "admin/content/node/delete", + * operations = { + * "delete" + * } * ) */ class DeleteNode extends ActionBase implements ContainerFactoryPluginInterface { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/DemoteNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/DemoteNode.php index e490ecb..1e55d88 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/DemoteNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/DemoteNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_unpromote_action", * label = @Translation("Demote selected content from front page"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class DemoteNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/PromoteNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/PromoteNode.php index 0cfc316..05d6af2 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/PromoteNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/PromoteNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_promote_action", * label = @Translation("Promote selected content to front page"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class PromoteNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/PublishNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/PublishNode.php index 20da55e..2f1265c 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/PublishNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/PublishNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_publish_action", * label = @Translation("Publish selected content"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class PublishNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/SaveNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/SaveNode.php index b758b72..31b1304 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/SaveNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/SaveNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_save_action", * label = @Translation("Save content"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class SaveNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/StickyNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/StickyNode.php index c4613ce..f6c79ef 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/StickyNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/StickyNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_make_sticky_action", * label = @Translation("Make selected content sticky"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class StickyNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishByKeywordNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishByKeywordNode.php index 953b0f4..57aef47 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishByKeywordNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishByKeywordNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_unpublish_by_keyword_action", * label = @Translation("Unpublish content containing keyword(s)"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class UnpublishByKeywordNode extends ConfigurableActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishNode.php index d462d6d..b3a9311 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/UnpublishNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_unpublish_action", * label = @Translation("Unpublish selected content"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class UnpublishNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Plugin/Action/UnstickyNode.php b/core/modules/node/lib/Drupal/node/Plugin/Action/UnstickyNode.php index 204b9d5..ac48363 100644 --- a/core/modules/node/lib/Drupal/node/Plugin/Action/UnstickyNode.php +++ b/core/modules/node/lib/Drupal/node/Plugin/Action/UnstickyNode.php @@ -15,7 +15,10 @@ * @Action( * id = "node_make_unsticky_action", * label = @Translation("Make selected content not sticky"), - * type = "node" + * type = "node", + * operations = { + * "update" + * } * ) */ class UnstickyNode extends ActionBase { diff --git a/core/modules/node/lib/Drupal/node/Tests/Views/BulkFormAccessTest.php b/core/modules/node/lib/Drupal/node/Tests/Views/BulkFormAccessTest.php new file mode 100644 index 0000000..dbe03f1 --- /dev/null +++ b/core/modules/node/lib/Drupal/node/Tests/Views/BulkFormAccessTest.php @@ -0,0 +1,120 @@ + 'Node: Bulk form entity access', + 'description' => 'Tests if a node bulk form respects entity access.', + 'group' => 'Views module integration' + ); + } + + protected function setUp() { + parent::setUp(); + + // After enabling a node access module, the access table has to be rebuild. + node_access_rebuild(); + + // Enable the private node feature of the node_access_test module. + \Drupal::state()->set('node_access_test.private', TRUE); + + $this->accessController = \Drupal::entityManager()->getAccessController('node'); + } + + /** + * Tests if nodes that may not be edited, can not be edited in bulk. + */ + public function testNodeEditAccess() { + // Create an account who will be the author of a private node. + $author = $this->drupalCreateUser(); + // Create a private node (author may view, edit and delete, others may not). + $node = $this->drupalCreateNode(array('private' => TRUE, 'uid' => $author->id())); + // Create an account that may view the private node, but not edit it. + $account = $this->drupalCreateUser(array('administer nodes', 'node test view')); + $this->drupalLogin($account); + + // Ensure the node is published. + $this->assertTrue($node->isPublished(), 'Node is initially published.'); + + // Ensure that the node can not be edited. + $this->assertEqual(FALSE, $this->accessController->access($node, 'update', $node->prepareLangcode(), $account), 'The node may not be edited.'); + + // Test editing the node using the bulk form. + $edit = array( + 'node_bulk_form[0]' => TRUE, + 'action' => 'node_unpublish_action', + ); + $this->drupalPostForm('test-node-bulk-form', $edit, t('Apply')); + // Re-load the node and check the status. + $node = entity_load('node', $node->id(), TRUE); + $this->assertTrue($node->isPublished(), 'The node is still published.'); + } + + /** + * Tests if nodes that may not be deleted, can not be deleted in bulk. + */ + public function testNodeDeleteAccess() { + // Create an account who will be the author of a private node. + $author = $this->drupalCreateUser(); + // Create a private node (author may view, edit and delete, others may not). + $private_node = $this->drupalCreateNode(array('private' => TRUE, 'uid' => $author->id())); + // Create an account that may view the private node, but not delete it. + $account = $this->drupalCreateUser(array('access content', 'administer nodes', 'node test view')); + // Create a node that may be deleted too, to ensure the delete confirmation + // page is shown later. In node_access_test.module, nodes may only be + // deleted by the author. + $own_node = $this->drupalCreateNode(array('private' => TRUE, 'uid' => $account->id())); + $this->drupalLogin($account); + + // Ensure that the private node can not be deleted. + $this->assertEqual(FALSE, $this->accessController->access($private_node, 'delete', $private_node->prepareLangcode(), $account), 'The private node may not be deleted.'); + // Ensure that the public node may be deleted. + $this->assertEqual(TRUE, $this->accessController->access($own_node, 'delete', $own_node->prepareLangcode(), $account), 'The public node may be deleted.'); + + // Try to delete the node using the bulk form. + $edit = array( + 'node_bulk_form[0]' => TRUE, + 'node_bulk_form[1]' => TRUE, + 'action' => 'node_delete_action', + ); + $this->drupalPostForm('test-node-bulk-form', $edit, t('Apply')); + $this->drupalPostForm(NULL, array(), t('Delete')); + // Ensure the private node still exists. + $private_node = entity_load('node', $private_node->id(), TRUE); + $this->assertNotNull($private_node, 'The private node has not been deleted.'); + // Ensure the own node is deleted. + $own_node = entity_load('node', $own_node->id(), TRUE); + $this->assertNull($own_node, 'The public node is deleted.'); + } +} diff --git a/core/modules/system/lib/Drupal/system/Plugin/views/field/BulkForm.php b/core/modules/system/lib/Drupal/system/Plugin/views/field/BulkForm.php index ad784e3..faa42f5 100644 --- a/core/modules/system/lib/Drupal/system/Plugin/views/field/BulkForm.php +++ b/core/modules/system/lib/Drupal/system/Plugin/views/field/BulkForm.php @@ -7,7 +7,9 @@ namespace Drupal\system\Plugin\views\field; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageControllerInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\Plugin\views\field\FieldPluginBase; use Drupal\views\Plugin\views\style\Table; @@ -259,14 +261,40 @@ public function viewsFormSubmit(&$form, &$form_state) { } $action = $this->actions[$form_state['values']['action']]; - $action->execute($entities); $operation_definition = $action->getPluginDefinition(); if (!empty($operation_definition['confirm_form_path'])) { $form_state['redirect'] = $operation_definition['confirm_form_path']; } - $count = count(array_filter($form_state['values'][$this->options['id']])); + // Check entity access. + $errors = array(); + foreach ($entities as $id => $entity) { + if (!$this->checkAccess($action, $entity)) { + $errors[] = $this->t('Skipped %operation on @type %title due to insufficient permissions.', array( + '%operation' => $action->label(), + '@type' => $entity->getEntityType()->getLabel(), + '%title' => $entity->label(), + )); + unset($entities[$id]); + } + } + $this->executeAction($action, $entities); + + // Check if there were errors. + if (count($errors) > 1) { + $message = array( + '#theme' => 'item_list', + '#items' => $errors, + ); + $message = drupal_render($message); + drupal_set_message($message, 'warning'); + } + elseif (count($errors) == 1) { + drupal_set_message(reset($errors), 'warning'); + } + + $count = count($entities); $action = $this->actions[$form_state['values']['action']]; if ($count) { drupal_set_message($this->translationManager()->formatPlural($count, '%action was applied to @count item.', '%action was applied to @count items.', array( @@ -278,6 +306,42 @@ public function viewsFormSubmit(&$form, &$form_state) { } /** + * Checks if action may be performed on entity. + * + * @param Drupal\system\Entity\Action $action + * The action to perform. + * @param Drupal\Core\Entity\EntityInterface $entity + * The entity to perform operation on. + * + * @return bool + * TRUE if the action may be performed. + * FALSE otherwise. + */ + protected function checkAccess($action, EntityInterface $entity, AccountInterface $account = NULL) { + $operation_definition = $action->getPluginDefinition(); + foreach ($operation_definition['operations'] as $operation) { + if (!$entity->access($operation, $account)) { + return FALSE; + } + } + return TRUE; + } + + /** + * Executes action on entities. + * + * @param Drupal\system\Entity\Action $action + * The action to perform. + * @param array $entities + * An array of entities to perform the action on. + * + * @return void + */ + protected function executeAction($action, array $entities) { + $action->execute($entities); + } + + /** * Returns the message to be displayed when there are no selected items. * * @return string diff --git a/core/modules/user/lib/Drupal/user/Plugin/Action/AddRoleUser.php b/core/modules/user/lib/Drupal/user/Plugin/Action/AddRoleUser.php index acf4dd9..ab26761 100644 --- a/core/modules/user/lib/Drupal/user/Plugin/Action/AddRoleUser.php +++ b/core/modules/user/lib/Drupal/user/Plugin/Action/AddRoleUser.php @@ -15,7 +15,10 @@ * @Action( * id = "user_add_role_action", * label = @Translation("Add a role to the selected users"), - * type = "user" + * type = "user", + * operations = { + * "update" + * } * ) */ class AddRoleUser extends ChangeUserRoleBase { diff --git a/core/modules/user/lib/Drupal/user/Plugin/Action/BlockUser.php b/core/modules/user/lib/Drupal/user/Plugin/Action/BlockUser.php index a488f31..c7db5bc 100644 --- a/core/modules/user/lib/Drupal/user/Plugin/Action/BlockUser.php +++ b/core/modules/user/lib/Drupal/user/Plugin/Action/BlockUser.php @@ -15,7 +15,10 @@ * @Action( * id = "user_block_user_action", * label = @Translation("Block the selected users"), - * type = "user" + * type = "user", + * operations = { + * "update" + * } * ) */ class BlockUser extends ActionBase { diff --git a/core/modules/user/lib/Drupal/user/Plugin/Action/CancelUser.php b/core/modules/user/lib/Drupal/user/Plugin/Action/CancelUser.php index b404ba0..de1e494 100644 --- a/core/modules/user/lib/Drupal/user/Plugin/Action/CancelUser.php +++ b/core/modules/user/lib/Drupal/user/Plugin/Action/CancelUser.php @@ -19,7 +19,10 @@ * id = "user_cancel_user_action", * label = @Translation("Cancel the selected user accounts"), * type = "user", - * confirm_form_path = "admin/people/cancel" + * confirm_form_path = "admin/people/cancel", + * operations = { + * "delete" + * } * ) */ class CancelUser extends ActionBase implements ContainerFactoryPluginInterface { diff --git a/core/modules/user/lib/Drupal/user/Plugin/Action/RemoveRoleUser.php b/core/modules/user/lib/Drupal/user/Plugin/Action/RemoveRoleUser.php index e63a70a..0d93e75 100644 --- a/core/modules/user/lib/Drupal/user/Plugin/Action/RemoveRoleUser.php +++ b/core/modules/user/lib/Drupal/user/Plugin/Action/RemoveRoleUser.php @@ -15,7 +15,10 @@ * @Action( * id = "user_remove_role_action", * label = @Translation("Remove a role from the selected users"), - * type = "user" + * type = "user", + * operations = { + * "update" + * } * ) */ class RemoveRoleUser extends ChangeUserRoleBase { diff --git a/core/modules/user/lib/Drupal/user/Plugin/Action/UnblockUser.php b/core/modules/user/lib/Drupal/user/Plugin/Action/UnblockUser.php index 9c30ebc..84e382c 100644 --- a/core/modules/user/lib/Drupal/user/Plugin/Action/UnblockUser.php +++ b/core/modules/user/lib/Drupal/user/Plugin/Action/UnblockUser.php @@ -15,7 +15,10 @@ * @Action( * id = "user_unblock_user_action", * label = @Translation("Unblock the selected users"), - * type = "user" + * type = "user", + * operations = { + * "update" + * } * ) */ class UnblockUser extends ActionBase { diff --git a/core/modules/user/lib/Drupal/user/Tests/Views/BulkFormAccessTest.php b/core/modules/user/lib/Drupal/user/Tests/Views/BulkFormAccessTest.php new file mode 100644 index 0000000..15574ea --- /dev/null +++ b/core/modules/user/lib/Drupal/user/Tests/Views/BulkFormAccessTest.php @@ -0,0 +1,101 @@ + 'User: Bulk form entity access', + 'description' => 'Tests if a user bulk form respects entity access.', + 'group' => 'Views module integration' + ); + } + + /** + * Tests if users that may not be edited, can not be edited in bulk. + */ + public function testUserEditAccess() { + // Create an authenticated user. + $account = $this->drupalCreateUser(array(), 'no_edit'); + // Ensure this account is not blocked. + $this->assertFalse($account->isBlocked(), 'The user is not blocked.'); + + // Login as user admin. + $this->drupalLogin($this->drupalCreateUser(array('administer users'))); + + // Ensure that the account "no_edit" can not be edited. + $this->drupalGet('user/' . $account->id() . '/edit'); + $this->assertResponse(403, 'The user may not be edited.'); + + // Test blocking the account "no_edit". + $edit = array( + 'user_bulk_form[' . ($account->id() -1) . ']' => TRUE, + 'action' => 'user_block_user_action', + ); + $this->drupalPostForm('test-user-bulk-form', $edit, t('Apply')); + + // Re-load the account "no_edit" and ensure it is still not blocked. + $account = entity_load('user', $account->id(), TRUE); + $this->assertFalse($account->isBlocked(), 'The user is not blocked.'); + } + + /** + * Tests if users that may not be deleted, can not be deleted in bulk. + */ + public function testUserDeleteAccess() { + // Create two authenticated users. + $account = $this->drupalCreateUser(array(), 'no_delete'); + $account2 = $this->drupalCreateUser(array(), 'may_delete'); + + // Login as user admin. + $this->drupalLogin($this->drupalCreateUser(array('administer users'))); + + // Ensure that the account "no_delete" can not be deleted. + $this->drupalGet('user/' . $account->id() . '/cancel'); + $this->assertResponse(403, 'The user "no_delete" may not be deleted.'); + // Ensure that the account "may_delete" *can* be deleted. + $this->drupalGet('user/' . $account2->id() . '/cancel'); + $this->assertResponse(200, 'The user "may_delete" may be deleted.'); + + // Test deleting the accounts "no_delete" and "may_delete". + $edit = array( + 'user_bulk_form[' . ($account->id() -1) . ']' => TRUE, + 'user_bulk_form[' . ($account2->id() -1) . ']' => TRUE, + 'action' => 'user_cancel_user_action', + ); + $this->drupalPostForm('test-user-bulk-form', $edit, t('Apply')); + $edit = array( + 'user_cancel_method' => 'user_cancel_delete', + ); + $this->drupalPostForm(NULL, $edit, t('Cancel accounts')); + + // Ensure the account "no_delete" still exists. + $account = entity_load('user', $account->id(), TRUE); + $this->assertNotNull($account, 'The user "no_delete" is not deleted.'); + // Ensure the account "may_delete" no longer exists. + $account = entity_load('user', $account2->id(), TRUE); + $this->assertNull($account, 'The user "may_delete" is deleted.'); + } +} diff --git a/core/modules/user/tests/modules/user_access_test/user_access_test.info.yml b/core/modules/user/tests/modules/user_access_test/user_access_test.info.yml new file mode 100644 index 0000000..77e0149 --- /dev/null +++ b/core/modules/user/tests/modules/user_access_test/user_access_test.info.yml @@ -0,0 +1,9 @@ +name: 'User access tests' +type: module +description: 'Support module for user access testing.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - user +hidden: true diff --git a/core/modules/user/tests/modules/user_access_test/user_access_test.module b/core/modules/user/tests/modules/user_access_test/user_access_test.module new file mode 100644 index 0000000..84b8c87 --- /dev/null +++ b/core/modules/user/tests/modules/user_access_test/user_access_test.module @@ -0,0 +1,20 @@ +getUsername() == "no_edit" && $operation == "update") { + // Deny edit access. + return FALSE; + } + if ($entity->getUsername() == "no_delete" && $operation == "delete") { + // Deny delete access. + return FALSE; + } +}