diff --git a/og.api.php b/og.api.php new file mode 100644 index 000000000..203da84a0 --- /dev/null +++ b/og.api.php @@ -0,0 +1,59 @@ +get('delete_published_groups'); + + // If deletion is not allowed and the group is published, revoke the + // permission. + $group = $context['group']; + if ($group instanceof EntityPublishedInterface && !$group->isPublished() && !$published_groups_can_be_deleted) { + $key = array_search(OgAccess::DELETE_GROUP_PERMISSION, $permissions); + if ($key !== FALSE) { + unset($permissions[$key]); + } + } + + // Since our access result depends on our custom module configuration, we need + // to add it to the cache metadata. + $cacheable_metadata->addCacheableDependency($config); +} + +/** + * @} End of "addtogroup hooks". + */ diff --git a/og.services.yml b/og.services.yml index be1821d89..f78b568d4 100644 --- a/og.services.yml +++ b/og.services.yml @@ -21,7 +21,7 @@ services: - { name: 'cache.context'} og.access: class: Drupal\og\OgAccess - arguments: ['@config.factory', '@current_user', '@module_handler', '@og.group_type_manager', '@og.permission_manager', '@og.membership_manager'] + arguments: ['@config.factory', '@current_user', '@module_handler', '@og.group_type_manager', '@og.permission_manager', '@og.membership_manager', '@event_dispatcher'] og.context: class: Drupal\og\ContextProvider\OgContext arguments: ['@plugin.manager.og.group_resolver', '@config.factory'] @@ -29,7 +29,7 @@ services: - { name: 'context_provider' } og.event_subscriber: class: Drupal\og\EventSubscriber\OgEventSubscriber - arguments: ['@og.permission_manager', '@entity_type.manager', '@entity_type.bundle.info'] + arguments: ['@og.permission_manager', '@entity_type.manager', '@entity_type.bundle.info', '@og.access'] tags: - { name: 'event_subscriber' } og.group_audience_helper: diff --git a/src/Event/AccessEventBase.php b/src/Event/AccessEventBase.php new file mode 100644 index 000000000..740864ce8 --- /dev/null +++ b/src/Event/AccessEventBase.php @@ -0,0 +1,98 @@ +group = $group; + $this->user = $user; + $this->access = AccessResult::neutral(); + } + + /** + * {@inheritdoc} + */ + public function grantAccess(): void { + $this->access = $this->access->orIf(AccessResult::allowed()); + } + + /** + * {@inheritdoc} + */ + public function denyAccess(): void { + $this->access = $this->access->orIf(AccessResult::forbidden()); + } + + /** + * {@inheritdoc} + */ + public function getGroup(): ContentEntityInterface { + return $this->group; + } + + /** + * {@inheritdoc} + */ + public function getUser(): AccountInterface { + return $this->user; + } + + /** + * {@inheritdoc} + */ + public function getAccessResult(): AccessResultInterface { + $access = $this->access; + + if ($access instanceof RefinableCacheableDependencyInterface) { + $access->addCacheableDependency($this); + } + + return $access; + } + +} diff --git a/src/Event/AccessEventInterface.php b/src/Event/AccessEventInterface.php new file mode 100644 index 000000000..01f23bf08 --- /dev/null +++ b/src/Event/AccessEventInterface.php @@ -0,0 +1,58 @@ +operation = $operation; + $this->groupContent = $groupContent; + } + + /** + * {@inheritdoc} + */ + public function getOperation(): string { + return $this->operation; + } + + /** + * {@inheritdoc} + */ + public function getGroupContent(): ContentEntityInterface { + return $this->groupContent; + } + +} diff --git a/src/Event/GroupContentEntityOperationAccessEventInterface.php b/src/Event/GroupContentEntityOperationAccessEventInterface.php new file mode 100644 index 000000000..649992852 --- /dev/null +++ b/src/Event/GroupContentEntityOperationAccessEventInterface.php @@ -0,0 +1,35 @@ +permissionManager = $permission_manager; $this->entityTypeManager = $entity_type_manager; $this->entityTypeBundleInfo = $entity_type_bundle_info; + $this->ogAccess = $og_access; } /** @@ -76,6 +89,7 @@ public static function getSubscribedEvents() { ], DefaultRoleEventInterface::EVENT_NAME => [['provideDefaultRoles']], OgAdminRoutesEventInterface::EVENT_NAME => [['provideOgAdminRoutes']], + GroupContentEntityOperationAccessEventInterface::EVENT_NAME => [['checkGroupContentEntityOperationAccess']], ]; } @@ -367,4 +381,53 @@ public function provideOgAdminRoutes(OgAdminRoutesEventInterface $event) { $event->setRoutesInfo($routes_info); } + /** + * Checks if a user has access to perform a group content entity operation. + * + * @param \Drupal\og\Event\GroupContentEntityOperationAccessEventInterface $event + * The event fired when a group content entity operation is performed. + */ + public function checkGroupContentEntityOperationAccess(GroupContentEntityOperationAccessEventInterface $event): void { + $group_content_entity = $event->getGroupContent(); + $group_entity = $event->getGroup(); + $user = $event->getUser(); + $operation = $event->getOperation(); + + // Check if the user owns the entity which is being operated on. + $is_owner = $group_content_entity instanceof EntityOwnerInterface && $group_content_entity->getOwnerId() == $user->id(); + + // Retrieve the group content entity operation permissions. + $group_entity_type_id = $group_entity->getEntityTypeId(); + $group_bundle_id = $group_entity->bundle(); + $group_content_bundle_ids = [$group_content_entity->getEntityTypeId() => [$group_content_entity->bundle()]]; + + $permissions = $this->permissionManager->getDefaultEntityOperationPermissions($group_entity_type_id, $group_bundle_id, $group_content_bundle_ids); + + // Filter the permissions by operation and ownership. + // If the user does not own the group content, only the non-owner permission + // is relevant (for example 'edit any article node'). However when the user + // _is_ the owner, then both permissions are relevant: an owner will have + // access if they either have the 'edit any article node' or the 'edit own + // article node' permission. + $ownerships = $is_owner ? [FALSE, TRUE] : [FALSE]; + $permissions = array_filter($permissions, function (GroupContentOperationPermission $permission) use ($operation, $ownerships) { + return $permission->getOperation() === $operation && in_array($permission->getOwner(), $ownerships); + }); + + if ($permissions) { + foreach ($permissions as $permission) { + // This currently does not handle access in the same way as Drupal core + // does - if any of the permissions grant access we will grant access. + // Once OgAccess::userAccess() is refactored to return a neutral result + // in case no access is determined we can just apply the result + // directly. + $access_result = $this->ogAccess->userAccess($group_entity, $permission->getName(), $user); + if ($access_result->isAllowed()) { + $event->grantAccess(); + break; + } + } + } + } + } diff --git a/src/OgAccess.php b/src/OgAccess.php index 7a0b15356..9471adaba 100644 --- a/src/OgAccess.php +++ b/src/OgAccess.php @@ -13,8 +13,10 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Session\AccountProxyInterface; use Drupal\og\Entity\OgRole; +use Drupal\og\Event\GroupContentEntityOperationAccessEvent; use Drupal\user\EntityOwnerInterface; use Drupal\user\UserInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * The service that determines if users have access to groups and group content. @@ -89,6 +91,13 @@ class OgAccess implements OgAccessInterface { */ protected $membershipManager; + /** + * The event dispatcher. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $dispatcher; + /** * Constructs the OgAccess service. * @@ -104,14 +113,17 @@ class OgAccess implements OgAccessInterface { * The permission manager. * @param \Drupal\og\MembershipManagerInterface $membership_manager * The group membership manager. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher + * The event dispatcher. */ - public function __construct(ConfigFactoryInterface $config_factory, AccountProxyInterface $account_proxy, ModuleHandlerInterface $module_handler, GroupTypeManagerInterface $group_manager, PermissionManagerInterface $permission_manager, MembershipManagerInterface $membership_manager) { + public function __construct(ConfigFactoryInterface $config_factory, AccountProxyInterface $account_proxy, ModuleHandlerInterface $module_handler, GroupTypeManagerInterface $group_manager, PermissionManagerInterface $permission_manager, MembershipManagerInterface $membership_manager, EventDispatcherInterface $dispatcher) { $this->configFactory = $config_factory; $this->accountProxy = $account_proxy; $this->moduleHandler = $module_handler; $this->groupTypeManager = $group_manager; $this->permissionManager = $permission_manager; $this->membershipManager = $membership_manager; + $this->dispatcher = $dispatcher; } /** @@ -327,46 +339,19 @@ public function userAccessGroupContentEntityOperation(string $operation, EntityI // Default to the current user. $user = $user ?: $this->accountProxy->getAccount(); - // Check if the user owns the entity which is being operated on. - $is_owner = $group_content_entity instanceof EntityOwnerInterface && $group_content_entity->getOwnerId() == $user->id(); - - // Retrieve the group content entity operation permissions. - $group_entity_type_id = $group_entity->getEntityTypeId(); - $group_bundle_id = $group_entity->bundle(); - $group_content_bundle_ids = [$group_content_entity->getEntityTypeId() => [$group_content_entity->bundle()]]; - - $permissions = $this->permissionManager->getDefaultEntityOperationPermissions($group_entity_type_id, $group_bundle_id, $group_content_bundle_ids); - - // Filter the permissions by operation and ownership. - // If the user does not own the group content, only the non-owner permission - // is relevant (for example 'edit any article node'). However when the user - // _is_ the owner, then both permissions are relevant: an owner will have - // access if they either have the 'edit any article node' or the 'edit own - // article node' permission. - $ownerships = $is_owner ? [FALSE, TRUE] : [FALSE]; - $permissions = array_filter($permissions, function (GroupContentOperationPermission $permission) use ($operation, $ownerships) { - return $permission->getOperation() === $operation && in_array($permission->getOwner(), $ownerships); - }); - - if ($permissions) { - foreach ($permissions as $permission) { - $user_access = $this->userAccess($group_entity, $permission->getName(), $user); - if ($user_access->isAllowed()) { - return $user_access; - } - } - } + $event = new GroupContentEntityOperationAccessEvent($operation, $group_entity, $group_content_entity, $user); // @todo This doesn't really vary by user but by the user's roles inside of // the group. We should create a cache context for OgRole entities. // @see https://github.com/amitaibu/og/issues/219 - $cacheable_metadata = new CacheableMetadata(); - $cacheable_metadata->addCacheableDependency($group_content_entity); + $event->addCacheableDependency($group_content_entity); if ($user->id() == $this->accountProxy->id()) { - $cacheable_metadata->addCacheContexts(['user']); + $event->addCacheContexts(['user']); } - return AccessResult::neutral()->addCacheableDependency($cacheable_metadata); + $this->dispatcher->dispatch(GroupContentEntityOperationAccessEvent::EVENT_NAME, $event); + + return $event->getAccessResult(); } /** diff --git a/tests/modules/og_test/og_test.install b/tests/modules/og_test/og_test.install new file mode 100644 index 000000000..9b3766ef6 --- /dev/null +++ b/tests/modules/og_test/og_test.install @@ -0,0 +1,15 @@ +delete('og_test_group_content_entity_operation_access_alter'); +} diff --git a/tests/modules/og_test/og_test.module b/tests/modules/og_test/og_test.module index de4a8e560..8a886f59b 100644 --- a/tests/modules/og_test/og_test.module +++ b/tests/modules/og_test/og_test.module @@ -7,6 +7,9 @@ declare(strict_types = 1); +use Drupal\Core\Access\AccessResultAllowed; +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\EntityInterface; use Drupal\og\Entity\OgRole; use Drupal\og\Og; @@ -41,3 +44,23 @@ function og_test_entity_insert(EntityInterface $entity) { $membership->save(); } } + +/** + * Implements hook_og_user_access_entity_operation_alter(). + */ +function og_test_og_user_access_entity_operation_alter(AccessResultInterface &$access_result, CacheableMetadata $cacheable_metadata, $context): void { + if (\Drupal::state()->get('og_test_group_content_entity_operation_access_alter', FALSE)) { + // Moderators should have access to edit and delete all comments in all + // groups. + /** @var \Drupal\Core\Session\AccountProxyInterface $user */ + $user = $context['user']; + $group_content = $context['group_content']; + + $is_comment = $group_content->getEntityTypeId() === 'comment'; + $user_can_moderate_comments = $user->hasPermission('edit and delete comments in all groups'); + + if ($is_comment && $user_can_moderate_comments) { + $access_result = new AccessResultAllowed(); + } + } +} diff --git a/tests/modules/og_test/og_test.permissions.yml b/tests/modules/og_test/og_test.permissions.yml new file mode 100644 index 000000000..cbf42a419 --- /dev/null +++ b/tests/modules/og_test/og_test.permissions.yml @@ -0,0 +1,3 @@ +# A permission given to users who can moderate comments in all groups. +edit and delete comments in all groups: + title: 'Moderate comments in all groups' diff --git a/tests/modules/og_test/og_test.services.yml b/tests/modules/og_test/og_test.services.yml new file mode 100644 index 000000000..31fe3b475 --- /dev/null +++ b/tests/modules/og_test/og_test.services.yml @@ -0,0 +1,6 @@ +services: + og_test.event_subscriber: + class: Drupal\og_test\EventSubscriber\OgTestEventSubscriber + arguments: ['@state'] + tags: + - { name: 'event_subscriber' } diff --git a/tests/modules/og_test/src/EventSubscriber/OgTestEventSubscriber.php b/tests/modules/og_test/src/EventSubscriber/OgTestEventSubscriber.php new file mode 100644 index 000000000..aa6300eb8 --- /dev/null +++ b/tests/modules/og_test/src/EventSubscriber/OgTestEventSubscriber.php @@ -0,0 +1,62 @@ +state = $state; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + GroupContentEntityOperationAccessEventInterface::EVENT_NAME => [['moderatorsCanManageComments']], + ]; + } + + /** + * Allows moderators to edit and delete comments in all groups. + * + * @param \Drupal\og\Event\GroupContentEntityOperationAccessEventInterface $event + * The event that fires when an entity operation is being performed on group + * content. + */ + public function moderatorsCanManageComments(GroupContentEntityOperationAccessEventInterface $event): void { + if ($this->state->get('og_test_group_content_entity_operation_access_alter', FALSE)) { + // Moderators should have access to edit and delete all comments in all + // groups. + $is_comment = $event->getGroupContent()->getEntityTypeId() === 'comment'; + $user_can_moderate_comments = $event->getUser()->hasPermission('edit and delete comments in all groups'); + + if ($is_comment && $user_can_moderate_comments) { + $event->grantAccess(); + } + } + } + +} diff --git a/tests/src/Kernel/Access/GroupContentOperationAccessAlterTest.php b/tests/src/Kernel/Access/GroupContentOperationAccessAlterTest.php new file mode 100644 index 000000000..3a6bd10e1 --- /dev/null +++ b/tests/src/Kernel/Access/GroupContentOperationAccessAlterTest.php @@ -0,0 +1,179 @@ +installEntitySchema('comment'); + $this->installEntitySchema('entity_test'); + $this->installEntitySchema('og_membership'); + $this->installEntitySchema('user'); + $this->installSchema('system', 'sequences'); + + $this->ogAccess = $this->container->get('og.access'); + + // Create a dummy user which will get UID 1. We cannot use this for testing + // since this user becomes the super administrator and is not suitable for + // testing access control. + User::create(['name' => $this->randomString()])->save(); + + // Create a test user with the 'moderator' role which has global permission + // to moderate comments in all groups, even ones they are not a member of. + $this->user = $this->createUser(['edit and delete comments in all groups']); + + // Create the test group along with a user that serves as the group owner. + $group_bundle = mb_strtolower($this->randomMachineName()); + $this->group = EntityTest::create([ + 'type' => $group_bundle, + 'name' => $this->randomString(), + 'user_id' => $this->createUser()->id(), + ]); + $this->group->save(); + + // Declare that the test entity type is a group type. + Og::groupTypeManager()->addGroup('entity_test', $group_bundle); + + // Create a group content type. + CommentType::create([ + 'id' => 'comment', + 'label' => 'Comment subscription', + 'target_entity_type_id' => 'entity_test', + ])->save(); + $settings = [ + 'field_storage_config' => [ + 'settings' => [ + 'target_type' => 'entity_test', + ], + ], + ]; + Og::createField(OgGroupAudienceHelperInterface::DEFAULT_FIELD, 'comment', 'comment', $settings); + + // Create a group content entity. + $values = [ + 'subject' => 'subscribe', + 'comment_type' => 'comment', + 'entity_id' => $this->group->id(), + 'entity_type' => 'entity_test', + 'field_name' => 'an_imaginary_field', + OgGroupAudienceHelperInterface::DEFAULT_FIELD => [['target_id' => $this->group->id()]], + ]; + $this->groupContent = Comment::create($values); + $this->groupContent->save(); + } + + /** + * Tests that modules can alter group content entity operation access. + * + * This mimicks a use case where a moderator has access to edit and delete + * comments in all groups. + * + * @see \og_test_og_user_access_entity_operation_alter() + * + * @dataProvider groupContentEntityOperationAccessAlterHookTestProvider + */ + public function testGroupContentEntityOperationAccessAlterHook(string $operation): void { + // Check that our test user doesn't have access to edit or delete comments + // in the group. + // This is the default behavior for users that are not a group member. + $this->assertFalse($this->userHasAccess($operation)); + + // Now enable our hook which will alter the group content entity operation + // access rules to allow moderators to edit and delete comments in all + // groups. Since our user is a moderator they should now have access. + \Drupal::state()->set('og_test_group_content_entity_operation_access_alter', TRUE); + $this->assertTrue($this->userHasAccess($operation)); + } + + /** + * Checks whether the test user has access to perform the entity operation. + * + * @param string $operation + * The entity operation to check. + * + * @return bool + * TRUE if the user has access, FALSE otherwise. + */ + protected function userHasAccess(string $operation): bool { + return $this->ogAccess->userAccessGroupContentEntityOperation($operation, $this->group, $this->groupContent, $this->user)->isAllowed(); + } + + /** + * Provides test data for ::testGroupContentEntityOperationAccessAlterHook(). + * + * @return string[][] + * Test cases for the 'update' and 'delete' entity operations. + */ + public function groupContentEntityOperationAccessAlterHookTestProvider(): array { + return [ + ['update'], + ['delete'], + ]; + } + +} diff --git a/tests/src/Unit/OgAccessTestBase.php b/tests/src/Unit/OgAccessTestBase.php index 8ac7ec545..eac63830a 100644 --- a/tests/src/Unit/OgAccessTestBase.php +++ b/tests/src/Unit/OgAccessTestBase.php @@ -23,6 +23,7 @@ use Drupal\user\EntityOwnerInterface; use Drupal\user\RoleInterface; use Prophecy\Argument; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * Base class for tests of the OgAccess class. @@ -180,6 +181,7 @@ protected function setUp(): void { $account_proxy = $this->prophesize(AccountProxyInterface::class); $module_handler = $this->prophesize(ModuleHandlerInterface::class); $this->permissionManager = $this->prophesize(PermissionManager::class); + $dispatcher = $this->prophesize(EventDispatcherInterface::class); // Instantiate the system under test. $this->ogAccess = new OgAccess( @@ -188,7 +190,8 @@ protected function setUp(): void { $module_handler->reveal(), $this->groupTypeManager->reveal(), $this->permissionManager->reveal(), - $this->membershipManager->reveal() + $this->membershipManager->reveal(), + $dispatcher->reveal() ); $container = new ContainerBuilder();