diff --git a/og.services.yml b/og.services.yml index aec743c17..ba492c4e5 100644 --- a/og.services.yml +++ b/og.services.yml @@ -1,14 +1,12 @@ services: - og.access: - class: Drupal\og\OgAccess - arguments: ['@config.factory', '@current_user', '@module_handler'] - access_check.og.user_access_group: class: Drupal\og\Access\GroupCheck arguments: ['@entity_type.manager', '@og.access'] tags: - { name: access_check, applies_to: _og_user_access_group } - + og.access: + class: Drupal\og\OgAccess + arguments: ['@config.factory', '@current_user', '@module_handler', '@og.group.manager', '@og.permission_manager'] og.event_subscriber: class: Drupal\og\EventSubscriber\OgEventSubscriber arguments: ['@og.permission_manager', '@entity_type.manager', '@entity_type.bundle.info'] diff --git a/src/Entity/OgMembership.php b/src/Entity/OgMembership.php index 562e7abd1..125ce6a93 100644 --- a/src/Entity/OgMembership.php +++ b/src/Entity/OgMembership.php @@ -200,7 +200,7 @@ public function getRoles() { /** * {@inheritdoc} */ - public function setRoles(array $roles = array()) { + public function setRoles(array $roles = []) { $role_ids = array_map(function (OgRole $role) { return $role->id(); }, $roles); @@ -223,6 +223,11 @@ public function getRolesIds() { * {@inheritdoc} */ public function hasPermission($permission) { + // Blocked users do not have any permissions. + if ($this->getState() === OgMembershipInterface::STATE_BLOCKED) { + return FALSE; + } + return array_filter($this->getRoles(), function (OgRole $role) use ($permission) { return $role->hasPermission($permission); }); @@ -263,9 +268,9 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setLabel(t('Group entity id.')) ->setDescription(t("The entity ID of the group.")); - $fields['state'] = BaseFieldDefinition::create('integer') + $fields['state'] = BaseFieldDefinition::create('string') ->setLabel(t('State')) - ->setDescription(t("The state of the group content.")) + ->setDescription(t('The user membership state: active, pending, or blocked.')) ->setDefaultValue(OgMembershipInterface::STATE_ACTIVE); $fields['roles'] = BaseFieldDefinition::create('entity_reference') diff --git a/src/Entity/OgRole.php b/src/Entity/OgRole.php index bd1f7e59c..58aa307e9 100644 --- a/src/Entity/OgRole.php +++ b/src/Entity/OgRole.php @@ -3,6 +3,7 @@ namespace Drupal\og\Entity; use Drupal\Core\Config\ConfigValueException; +use Drupal\Core\Entity\EntityInterface; use Drupal\og\Exception\OgRoleException; use Drupal\og\OgRoleInterface; use Drupal\user\Entity\Role; @@ -36,6 +37,16 @@ */ class OgRole extends Role implements OgRoleInterface { + /** + * Constructs an OgRole object. + * + * @param array $values + * An array of values to set, keyed by property name. + */ + public function __construct(array $values) { + parent::__construct($values, 'og_role'); + } + /** * Sets the ID of the role. * @@ -198,6 +209,14 @@ public function setName($name) { return $this; } + /** + * {@inheritdoc} + */ + public static function loadByGroupAndName(EntityInterface $group, $name) { + $role_id = "{$group->getEntityTypeId()}-{$group->bundle()}-$name"; + return self::load($role_id); + } + /** * {@inheritdoc} */ @@ -205,8 +224,8 @@ public function save() { // The ID of a new OgRole has to consist of the entity type ID, bundle ID // and role name, separated by dashes. if ($this->isNew() && $this->id()) { - list($entity_type_id, $bundle_id, $name) = explode('-', $this->id()); - if ($entity_type_id !== $this->getGroupType() || $bundle_id !== $this->getGroupBundle() || $name !== $this->getName()) { + $pattern = preg_quote("{$this->getGroupType()}-{$this->getGroupBundle()}-{$this->getName()}"); + if (!preg_match("/$pattern/", $this->id())) { throw new ConfigValueException('The ID should consist of the group entity type ID, group bundle ID and role name, separated by dashes.'); } } @@ -257,6 +276,7 @@ public function set($property_name, $value) { 'group_type', 'group_bundle', ]); + if (!$is_locked_property || $this->isNew()) { return parent::set($property_name, $value); } diff --git a/src/GroupContentOperationPermission.php b/src/GroupContentOperationPermission.php index c359bb756..8c86304c5 100644 --- a/src/GroupContentOperationPermission.php +++ b/src/GroupContentOperationPermission.php @@ -43,7 +43,7 @@ class GroupContentOperationPermission extends Permission { * FALSE if this permission applies to all entities, TRUE if it only applies * to the entities owned by the user. */ - protected $owner = 'any'; + protected $owner = FALSE; /** * Returns the group content entity type ID to which this permission applies. diff --git a/src/GroupManager.php b/src/GroupManager.php index d944d60ac..5cefea2df 100644 --- a/src/GroupManager.php +++ b/src/GroupManager.php @@ -239,7 +239,7 @@ public function getGroupContentBundleIdsByGroupBundle($group_entity_type_id, $gr } /** - * Sets an entity type instance as being an OG group. + * Declares a bundle of an entity type as being an OG group. * * @param string $entity_type_id * The entity type ID of the bundle to declare as being a group. diff --git a/src/Og.php b/src/Og.php index f7aacfde1..857a312bd 100644 --- a/src/Og.php +++ b/src/Og.php @@ -249,8 +249,8 @@ public static function getMemberships(AccountInterface $user, array $states = [O * (optional) Array with the state to return. Defaults to active. * * @return \Drupal\og\Entity\OgMembership|null - * The OgMembership entity, or NULL if the user is not a member of the - * group. + * The OgMembership entity. NULL will be returned if no membership is + * available that matches the passed in $states. */ public static function getMembership(EntityInterface $group, AccountInterface $user, array $states = [OgMembershipInterface::STATE_ACTIVE]) { foreach (static::getMemberships($user, $states) as $membership) { @@ -258,6 +258,9 @@ public static function getMembership(EntityInterface $group, AccountInterface $u return $membership; } } + + // No membership matches the request. + return NULL; } /** @@ -696,7 +699,7 @@ protected static function getFieldBaseDefinition($plugin_id) { * @param array $options * Overriding the default options of the selection handler. * - * @return OgSelection + * @return \Drupal\og\Plugin\EntityReferenceSelection\OgSelection * Returns the OG selection handler. * * @throws \Exception diff --git a/src/OgAccess.php b/src/OgAccess.php index 47c1d30b7..dcc506bcb 100644 --- a/src/OgAccess.php +++ b/src/OgAccess.php @@ -10,6 +10,7 @@ use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Session\AccountProxyInterface; +use Drupal\og\Entity\OgRole; use Drupal\user\EntityOwnerInterface; /** @@ -62,6 +63,22 @@ class OgAccess implements OgAccessInterface { */ protected $moduleHandler; + /** + * The group manager. + * + * @var \Drupal\og\GroupManager + * + * @todo This should be GroupManagerInterface. + */ + protected $groupManager; + + /** + * The OG permission manager. + * + * @var \Drupal\og\PermissionManagerInterface + */ + protected $permissionManager; + /** * Constructs an OgManager service. * @@ -71,11 +88,17 @@ class OgAccess implements OgAccessInterface { * The service that contains the current active user. * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler * The module handler. + * @param \Drupal\og\GroupManager $group_manager + * The group manager. + * @param \Drupal\og\PermissionManagerInterface $permission_manager + * The permission manager. */ - public function __construct(ConfigFactoryInterface $config_factory, AccountProxyInterface $account_proxy, ModuleHandlerInterface $module_handler) { + public function __construct(ConfigFactoryInterface $config_factory, AccountProxyInterface $account_proxy, ModuleHandlerInterface $module_handler, GroupManager $group_manager, PermissionManagerInterface $permission_manager) { $this->configFactory = $config_factory; $this->accountProxy = $account_proxy; $this->moduleHandler = $module_handler; + $this->groupManager = $group_manager; + $this->permissionManager = $permission_manager; } /** @@ -89,7 +112,7 @@ public function userAccess(EntityInterface $group, $operation, AccountInterface $config = $this->configFactory->get('og.settings'); $cacheable_metadata = (new CacheableMetadata) ->addCacheableDependency($config); - if (!Og::isGroup($group_type_id, $bundle)) { + if (!$this->groupManager->isGroup($group_type_id, $bundle)) { // Not a group. return AccessResult::neutral()->addCacheableDependency($cacheable_metadata); } @@ -100,6 +123,9 @@ public function userAccess(EntityInterface $group, $operation, AccountInterface // From this point on, every result also depends on the user so check // whether it is the current. See https://www.drupal.org/node/2628870 + // @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 if ($user->id() == $this->accountProxy->id()) { $cacheable_metadata->addCacheContexts(['user']); } @@ -140,22 +166,30 @@ public function userAccess(EntityInterface $group, $operation, AccountInterface $permissions = []; $user_is_group_admin = FALSE; - if ($membership = Og::getMembership($group, $user)) { - foreach ($membership->getRoles() as $role) { - // Check for the is_admin flag. - /** @var \Drupal\og\Entity\OgRole $role */ - if ($role->isAdmin()) { - $user_is_group_admin = TRUE; - break; + $states = [ + OgMembershipInterface::STATE_ACTIVE, + OgMembershipInterface::STATE_PENDING, + OgMembershipInterface::STATE_BLOCKED, + ]; + if ($membership = Og::getMembership($group, $user, $states)) { + // Blocked users don't have any permissions. + if ($membership->getState() !== OgMembershipInterface::STATE_BLOCKED) { + foreach ($membership->getRoles() as $role) { + // Check for the is_admin flag. + /** @var \Drupal\og\Entity\OgRole $role */ + if ($role->isAdmin()) { + $user_is_group_admin = TRUE; + break; + } + + $permissions = array_merge($permissions, $role->getPermissions()); } - - $permissions = array_merge($permissions, $role->getPermissions()); } } else { // User is a non-member. /** @var \Drupal\og\Entity\OgRole $role */ - $role = Og::getRole($group_type_id, $bundle, OgRoleInterface::ANONYMOUS); + $role = OgRole::loadByGroupAndName($group, OgRoleInterface::ANONYMOUS); $permissions = $role->getPermissions(); } @@ -198,16 +232,11 @@ public function userAccess(EntityInterface $group, $operation, AccountInterface public function userAccessEntity($operation, EntityInterface $entity, AccountInterface $user = NULL) { $result = AccessResult::neutral(); - // Entity isn't saved yet. - if ($entity->isNew()) { - return $result->addCacheableDependency($entity); - } - $entity_type = $entity->getEntityType(); $entity_type_id = $entity_type->id(); $bundle = $entity->bundle(); - if (Og::isGroup($entity_type_id, $bundle)) { + if ($this->groupManager->isGroup($entity_type_id, $bundle)) { $user_access = $this->userAccess($entity, $operation, $user); if ($user_access->isAllowed()) { return $user_access; @@ -233,6 +262,15 @@ public function userAccessEntity($operation, EntityInterface $entity, AccountInt $forbidden = AccessResult::forbidden()->addCacheTags($cache_tags); foreach ($groups as $entity_groups) { foreach ($entity_groups as $group) { + // Check if the operation matches a group content entity operation + // such as 'create article content'. + $operation_access = $this->userAccessGroupContentEntityOperation($operation, $group, $entity, $user); + if ($operation_access->isAllowed()) { + return $operation_access->addCacheTags($cache_tags); + } + + // Check if the operation matches a group level operation such as + // 'subscribe without approval'. $user_access = $this->userAccess($group, $operation, $user); if ($user_access->isAllowed()) { return $user_access->addCacheTags($cache_tags); @@ -252,6 +290,77 @@ public function userAccessEntity($operation, EntityInterface $entity, AccountInt return $result; } + /** + * Checks access for entity operations on group content entities. + * + * This checks if the user has permission to perform the requested operation + * on the given group content entity according to the user's membership status + * in the given group. There is no formal support for access control on entity + * operations in core, so the mapping of permissions to operations is provided + * by PermissionManager::getEntityOperationPermissions(). + * + * @param string $operation + * The entity operation. + * @param \Drupal\Core\Entity\EntityInterface $group_entity + * The group entity, to retrieve the permissions from. + * @param \Drupal\Core\Entity\EntityInterface $group_content_entity + * The group content entity for which access to the entity operation is + * requested. + * @param \Drupal\Core\Session\AccountInterface $user + * Optional user for which to check access. If omitted, the currently logged + * in user will be used. + * + * @return \Drupal\Core\Access\AccessResult + * The access result object. + * + * @see \Drupal\og\PermissionManager::getEntityOperationPermissions() + */ + public function userAccessGroupContentEntityOperation($operation, EntityInterface $group_entity, EntityInterface $group_content_entity, AccountInterface $user = NULL) { + // 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; + } + } + } + + // @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); + if ($user->id() == $this->accountProxy->id()) { + $cacheable_metadata->addCacheContexts(['user']); + } + + return AccessResult::neutral()->addCacheableDependency($cacheable_metadata); + } + /** * Set the permissions in the static cache. * diff --git a/src/OgMembershipInterface.php b/src/OgMembershipInterface.php index 273d6fc79..c4f515263 100644 --- a/src/OgMembershipInterface.php +++ b/src/OgMembershipInterface.php @@ -18,7 +18,7 @@ interface OgMembershipInterface extends ContentEntityInterface { * When a user has this membership state they are considered to be of * "member" role. */ - const STATE_ACTIVE = 1; + const STATE_ACTIVE = 'active'; /** * Define pending group content states. @@ -28,16 +28,16 @@ interface OgMembershipInterface extends ContentEntityInterface { * When a user has this membership state they are considered to be of * "non-member" role. */ - const STATE_PENDING = 2; + const STATE_PENDING = 'pending'; /** * Define blocked group content states. The user is rejected from the group. * * When a user has this membership state they are denied access to any - * group related action. This state, however, does not prevent user to - * access a group or group content node. + * group related action. This state, however, does not prevent the user from + * accessing a group or group content. */ - const STATE_BLOCKED = 3; + const STATE_BLOCKED = 'blocked'; /** * The default group membership type that is the bundle of group membership. diff --git a/src/OgRoleInterface.php b/src/OgRoleInterface.php index a53f6b088..146eadf12 100644 --- a/src/OgRoleInterface.php +++ b/src/OgRoleInterface.php @@ -2,6 +2,8 @@ namespace Drupal\og; +use Drupal\Core\Entity\EntityInterface; + /** * Provides an interface defining an OG user role entity. * @@ -65,4 +67,17 @@ public function getName(); */ public function setName($name); + /** + * Returns the role represented by the given group and role name. + * + * @param \Drupal\Core\Entity\EntityInterface $group + * The group for which to return the role. + * @param string $name + * The role name for which to return the role. + * + * @return \Drupal\og\OgRoleInterface + * The role. + */ + public static function loadByGroupAndName(EntityInterface $group, $name); + } diff --git a/src/PermissionManager.php b/src/PermissionManager.php index b3f170520..1ce06c35d 100644 --- a/src/PermissionManager.php +++ b/src/PermissionManager.php @@ -32,7 +32,7 @@ public function __construct(EventDispatcherInterface $event_dispatcher) { * {@inheritdoc} */ public function getDefaultPermissions($group_entity_type_id, $group_bundle_id, array $group_content_bundle_ids, $role_name = NULL) { - $event = new PermissionEvent($group_entity_type_id, $group_bundle_id, []); + $event = new PermissionEvent($group_entity_type_id, $group_bundle_id, $group_content_bundle_ids); $this->eventDispatcher->dispatch(PermissionEventInterface::EVENT_NAME, $event); return $event->getPermissions(); } diff --git a/tests/src/Kernel/Access/OgAccessHookTest.php b/tests/src/Kernel/Access/OgAccessHookTest.php new file mode 100644 index 000000000..6f99df31b --- /dev/null +++ b/tests/src/Kernel/Access/OgAccessHookTest.php @@ -0,0 +1,251 @@ +installConfig(['og']); + $this->installEntitySchema('block_content'); + $this->installEntitySchema('node'); + $this->installEntitySchema('og_membership'); + $this->installEntitySchema('user'); + $this->installSchema('system', 'sequences'); + + // Create two roles: one for normal users, and one for administrators. + foreach (['authenticated', 'administrator'] as $role_id) { + $role = Role::create([ + 'id' => $role_id, + 'label' => $role_id, + ]); + $role->grantPermission('access content'); + + // Grant the 'administer group' permission to the administrator role. + if ($role_id === 'administrator') { + $role->grantPermission('administer group'); + } + $role->save(); + $this->roles[$role_id] = $role; + } + + // Create a test user for each membership type. + $membership_types = [ + // The group owner. + 'owner', + // A site administrator with the right to administer all groups. + 'group-admin', + // A regular member of the group. + 'member', + // A user that is not a member of the group. + 'non-member', + // A blocked user. + 'blocked', + ]; + foreach ($membership_types as $membership_type) { + $user = User::create([ + 'name' => $membership_type, + ]); + // Grant the 'administrator' role to the group administrator. + if ($membership_type === 'group-admin') { + $user->addRole('administrator'); + } + $user->save(); + $this->users[$membership_type] = $user; + } + + // Create a "group" bundle on the Custom Block entity type and turn it into + // a group. Note we're not using the Entity Test entity for this since it + // does not have real support for multiple bundles. + BlockContentType::create(['type' => 'group']); + Og::groupManager()->addGroup('block_content', 'group'); + + // Create a group. + $this->group = BlockContent::create([ + 'title' => $this->randomString(), + 'type' => 'group', + 'uid' => $this->users['owner']->id(), + ]); + $this->group->save(); + + // Create a group content type. + $type = NodeType::create([ + 'type' => 'group_content', + 'name' => $this->randomString(), + ]); + $type->save(); + $settings = [ + 'field_storage_config' => [ + 'settings' => [ + 'target_type' => 'block_content', + ], + ], + ]; + Og::createField(OgGroupAudienceHelper::DEFAULT_FIELD, 'node', 'group_content', $settings); + + // Grant members permission to edit their own content. + /** @var \Drupal\og\Entity\OgRole $role */ + $role = $this->container->get('entity_type.manager') + ->getStorage('og_role') + ->load('block_content-group-member'); + $role->grantPermission('edit own group_content content'); + $role->save(); + + // Subscribe the normal member and the blocked member to the group. + foreach (['member', 'blocked'] as $membership_type) { + $state = $membership_type === 'member' ? OgMembershipInterface::STATE_ACTIVE : OgMembershipInterface::STATE_BLOCKED; + /** @var \Drupal\og\Entity\OgMembership $membership */ + $membership = OgMembership::create(); + $membership + ->setUser($this->users[$membership_type]) + ->setGroup($this->group) + ->addRole($role) + ->setState($state) + ->save(); + } + + // Create three group content items, one owned by the group owner, one by + // the member, and one by the blocked user. + foreach (['owner', 'member', 'blocked'] as $membership_type) { + $this->groupContent[$membership_type] = Node::create([ + 'title' => $this->randomString(), + 'type' => 'group_content', + 'uid' => $this->users[$membership_type]->id(), + OgGroupAudienceHelper::DEFAULT_FIELD => [['target_id' => $this->group->id()]], + ]); + $this->groupContent[$membership_type]->save(); + } + } + + /** + * Tests access to entity operations through the access hook. + * + * @param string $user + * The name of the user to test. + * @param array $expected_results + * An associative array indicating whether the user should have the right to + * edit content owned by the user represented by the array key. + * + * @dataProvider entityOperationAccessProvider + */ + public function testEntityOperationAccess($user, array $expected_results) { + foreach ($expected_results as $group_content => $expected_result) { + /** @var \Drupal\Core\Access\AccessResult $result */ + $result = og_entity_access($this->groupContent[$group_content], 'update', $this->users[$user]); + $this->assertEquals($expected_result, $result->isAllowed()); + } + } + + /** + * Data provider for ::testEntityOperationAccess(). + * + * @return array + * And array of test data sets. Each set consisting of: + * - The name of the user to test. + * - An associative array indicating whether the user should have the right + * to edit content owned by the user represented by the array key. + */ + public function entityOperationAccessProvider() { + return [ + [ + // The administrator should have the right to edit group content items + // owned by any user. + 'group-admin', + [ + 'owner' => TRUE, + 'member' => TRUE, + 'blocked' => TRUE, + ], + ], + [ + // Members should only have the right to edit their own group content. + 'member', + [ + 'owner' => FALSE, + 'member' => TRUE, + 'blocked' => FALSE, + ], + ], + [ + // The non-member cannot edit any group content. + 'non-member', + [ + 'owner' => FALSE, + 'member' => FALSE, + 'blocked' => FALSE, + ], + ], + [ + // The blocked member cannot edit any group content, not even their own. + 'blocked', + [ + 'owner' => FALSE, + 'member' => FALSE, + 'blocked' => FALSE, + ], + ], + ]; + } + +} diff --git a/tests/src/Kernel/Access/OgEntityAccessTest.php b/tests/src/Kernel/Access/OgEntityAccessTest.php index 9048c438d..054101d1c 100644 --- a/tests/src/Kernel/Access/OgEntityAccessTest.php +++ b/tests/src/Kernel/Access/OgEntityAccessTest.php @@ -160,7 +160,7 @@ protected function setUp() { $this->adminUser = User::create(['name' => $this->randomString()]); $this->adminUser->save(); - // Define the group content as group. + // Declare the test entity as being a group. Og::groupManager()->addGroup('entity_test', $this->groupBundle); // Create a group and associate with user 1. @@ -280,7 +280,7 @@ public function testAccess() { // Allow the permission to a non-member user. /** @var OgRole $role */ - $role = Og::getRole('entity_test', $this->groupBundle, OgRoleInterface::ANONYMOUS); + $role = OgRole::loadByGroupAndName($this->group1, OgRoleInterface::ANONYMOUS); $role ->grantPermission('some_perm') ->save(); diff --git a/tests/src/Kernel/Access/OgGroupContentOperationAccessTest.php b/tests/src/Kernel/Access/OgGroupContentOperationAccessTest.php new file mode 100644 index 000000000..222d6394f --- /dev/null +++ b/tests/src/Kernel/Access/OgGroupContentOperationAccessTest.php @@ -0,0 +1,377 @@ +installConfig(['og']); + $this->installEntitySchema('entity_test'); + $this->installEntitySchema('comment'); + $this->installEntitySchema('node'); + $this->installEntitySchema('og_membership'); + $this->installEntitySchema('user'); + $this->installSchema('system', 'sequences'); + + $this->entityTypeManager = $this->container->get('entity_type.manager'); + + $this->groupBundle = Unicode::strtolower($this->randomMachineName()); + + // Create a test user with UID 1. This user has universal access. + $this->users['uid1'] = User::create(['name' => $this->randomString()]); + $this->users['uid1']->save(); + + // Create a user that will serve as the group owner. There are no special + // permissions granted to the group owner, so this user can be used for + // creating entities that are not owned by the user under test. + $this->users['group_owner'] = User::create(['name' => $this->randomString()]); + $this->users['group_owner']->save(); + + // Declare that the test entity is a group type. + Og::groupManager()->addGroup('entity_test', $this->groupBundle); + + // Create the test group. + $this->group = EntityTest::create([ + 'type' => $this->groupBundle, + 'name' => $this->randomString(), + 'user_id' => $this->users['group_owner']->id(), + ]); + $this->group->save(); + + // Create 3 test roles with associated permissions. We will simulate a + // project that has two group content types: + // - 'newsletter': Any registered user can create entities of this type, + // even if they are not a member of the group. + // - 'article': These can only be created by group members. Normal members + // can edit and delete their own articles, while admins can edit and + // delete any article. + $permission_matrix = [ + OgRoleInterface::ANONYMOUS => [ + 'create newsletter comment', + 'update own newsletter comment', + 'delete own newsletter comment', + ], + OgRoleInterface::AUTHENTICATED => [ + 'create newsletter comment', + 'update own newsletter comment', + 'delete own newsletter comment', + 'create article content', + 'edit own article content', + 'delete own article content', + ], + // The administrator is not explicitly granted permission to edit or + // delete their own group content. Having the 'any' permission should be + // sufficient to also be able to edit their own content. + OgRoleInterface::ADMINISTRATOR => [ + 'create newsletter comment', + 'update any newsletter comment', + 'delete any newsletter comment', + 'create article content', + 'edit any article content', + 'delete any article content', + ], + ]; + + foreach ($permission_matrix as $role_name => $permissions) { + $this->roles[$role_name] = OgRole::loadByGroupAndName($this->group, $role_name); + foreach ($permissions as $permission) { + $this->roles[$role_name]->grantPermission($permission); + } + $this->roles[$role_name]->save(); + + // Create a test user with this role. + $this->users[$role_name] = User::create(['name' => $this->randomString()]); + $this->users[$role_name]->save(); + + // Subscribe the user to the group. + // Skip creation of the membership for the non-member user. It is actually + // fine to save this membership, but in the most common use case this + // membership will not exist in the database. + if ($role_name !== OgRoleInterface::ANONYMOUS) { + /** @var OgMembership $membership */ + $membership = OgMembership::create(); + $membership + ->setUser($this->users[$role_name]) + ->setGroup($this->group) + ->addRole($this->roles[$role_name]) + ->setState(OgMembershipInterface::STATE_ACTIVE) + ->save(); + } + } + + // Create a 'blocked' user. This user is identical to the normal + // 'authenticated' member, except that she has the 'blocked' state. + $this->users['blocked'] = User::create(['name' => $this->randomString()]); + $this->users['blocked']->save(); + $membership = OgMembership::create(); + $membership + ->setUser($this->users['blocked']) + ->setGroup($this->group) + ->addRole($this->roles[OgRoleInterface::AUTHENTICATED]) + ->setState(OgMembershipInterface::STATE_BLOCKED) + ->save(); + + // Create a 'newsletter' group content type. We are using the Comment entity + // for this to verify that this functionality works for all entity types. We + // cannot use the 'entity_test' entity for this since it has no support for + // bundles. Let's imagine that we have a use case where the user can leave a + // comment with the text 'subscribe' in order to subscribe to the + // newsletter. + CommentType::create([ + 'id' => 'newsletter', + 'label' => 'Newsletter subscription', + 'target_entity_type_id' => 'entity_test', + ])->save(); + $settings = [ + 'field_storage_config' => [ + 'settings' => [ + 'target_type' => 'entity_test', + ], + ], + ]; + Og::createField(OgGroupAudienceHelper::DEFAULT_FIELD, 'comment', 'newsletter', $settings); + + // Create an 'article' group content type. + NodeType::create([ + 'type' => 'article', + 'name' => 'Article', + ])->save(); + Og::createField(OgGroupAudienceHelper::DEFAULT_FIELD, 'node', 'article', $settings); + + // Create a group content entity owned by each test user, for both the + // 'newsletter' and 'article' bundles. + $user_ids = [ + 'uid1', + 'group_owner', + OgRoleInterface::ANONYMOUS, + OgRoleInterface::AUTHENTICATED, + OgRoleInterface::ADMINISTRATOR, + 'blocked', + ]; + foreach (['newsletter', 'article'] as $bundle_id) { + foreach ($user_ids as $user_id) { + $entity_type = $bundle_id === 'article' ? 'node' : 'comment'; + + switch ($entity_type) { + case 'node': + $values = [ + 'title' => $this->randomString(), + 'type' => $bundle_id, + OgGroupAudienceHelper::DEFAULT_FIELD => [['target_id' => $this->group->id()]], + ]; + break; + + case 'comment': + $values = [ + 'subject' => 'subscribe', + 'comment_type' => $bundle_id, + 'entity_id' => $this->group->id(), + 'entity_type' => 'entity_test', + OgGroupAudienceHelper::DEFAULT_FIELD => [['target_id' => $this->group->id()]], + ]; + break; + } + + $entity = $this->entityTypeManager->getStorage($entity_type)->create($values); + $entity->setOwner($this->users[$user_id]); + $entity->save(); + + $this->groupContent[$bundle_id][$user_id] = $entity; + } + } + } + + /** + * Test access to group content entity operations. + * + * @dataProvider accessProvider + */ + public function testAccess($group_content_bundle_id, $expected_access_matrix) { + /** @var \Drupal\og\OgAccessInterface $og_access */ + $og_access = $this->container->get('og.access'); + + foreach ($expected_access_matrix as $user_id => $operations) { + foreach ($operations as $operation => $ownerships) { + foreach ($ownerships as $ownership => $expected_access) { + // Depending on whether we're testing access to a user's own entity, + // use either the entity owned by the user, or the one used by the + // group owner. + $entity = $ownership === 'own' ? $this->groupContent[$group_content_bundle_id][$user_id] : $this->groupContent[$group_content_bundle_id]['group_owner']; + $user = $this->users[$user_id]; + $this->assertEquals($expected_access, $og_access->userAccessEntity($operation, $entity, $user)->isAllowed(), "Operation: $operation, ownership: $ownership, user: $user_id, bundle: $group_content_bundle_id"); + } + } + } + } + + /** + * Data provider for ::testAccess(). + * + * @return array + * And array of test data sets. Each set consisting of: + * - A string representing the group content bundle ID upon which the + * operation is performed. Can be either 'newsletter' or 'article'. + * - An array mapping the different users and the possible operations, and + * whether or not the user is expected to be granted access to this + * operation, depending on whether they own the content or not. + */ + public function accessProvider() { + return [ + [ + 'newsletter', + [ + // The super user and the administrator have the right to create, + // update and delete any newsletter subscription. + 'uid1' => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => TRUE], + 'delete' => ['own' => TRUE, 'any' => TRUE], + ], + OgRoleInterface::ADMINISTRATOR => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => TRUE], + 'delete' => ['own' => TRUE, 'any' => TRUE], + ], + // Non-members and members have the right to subscribe to the + // newsletter, and to manage or delete their own newsletter + // subscriptions. + OgRoleInterface::ANONYMOUS => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => FALSE], + 'delete' => ['own' => TRUE, 'any' => FALSE], + ], + OgRoleInterface::AUTHENTICATED => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => FALSE], + 'delete' => ['own' => TRUE, 'any' => FALSE], + ], + // Blocked users cannot do anything, not even update or delete their + // own content. + 'blocked' => [ + 'create' => ['any' => FALSE], + 'update' => ['own' => FALSE, 'any' => FALSE], + 'delete' => ['own' => FALSE, 'any' => FALSE], + ], + ], + ], + [ + 'article', + [ + // The super user and the administrator have the right to create, + // update and delete any article. + 'uid1' => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => TRUE], + 'delete' => ['own' => TRUE, 'any' => TRUE], + ], + OgRoleInterface::ADMINISTRATOR => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => TRUE], + 'delete' => ['own' => TRUE, 'any' => TRUE], + ], + // Non-members do not have the right to create or manage any article. + OgRoleInterface::ANONYMOUS => [ + 'create' => ['any' => FALSE], + 'update' => ['own' => FALSE, 'any' => FALSE], + 'delete' => ['own' => FALSE, 'any' => FALSE], + ], + // Members have the right to create new articles, and to manage their + // own articles. + OgRoleInterface::AUTHENTICATED => [ + 'create' => ['any' => TRUE], + 'update' => ['own' => TRUE, 'any' => FALSE], + 'delete' => ['own' => TRUE, 'any' => FALSE], + ], + // Blocked users cannot do anything, not even update or delete their + // own content. + 'blocked' => [ + 'create' => ['any' => FALSE], + 'update' => ['own' => FALSE, 'any' => FALSE], + 'delete' => ['own' => FALSE, 'any' => FALSE], + ], + ], + ], + ]; + } + +} diff --git a/tests/src/Kernel/Access/WikiTest.php b/tests/src/Kernel/Access/WikiTest.php new file mode 100644 index 000000000..d9aefe621 --- /dev/null +++ b/tests/src/Kernel/Access/WikiTest.php @@ -0,0 +1,227 @@ +installConfig(['og']); + $this->installEntitySchema('block_content'); + $this->installEntitySchema('node'); + $this->installEntitySchema('og_membership'); + $this->installEntitySchema('user'); + $this->installSchema('system', 'sequences'); + + // Create a user role for a standard authenticated user. + $role = Role::create([ + 'id' => 'authenticated', + 'label' => 'authenticated', + ]); + $role->grantPermission('access content'); + $role->save(); + + // Create a test user for each membership type. + $membership_types = [ + // The group owner. + 'owner', + // A regular member of the group. + 'member', + // A user that is not a member of the group. + 'non-member', + // A blocked user. + 'blocked', + ]; + foreach ($membership_types as $membership_type) { + $user = User::create([ + 'name' => $membership_type, + ]); + $user->save(); + $this->users[$membership_type] = $user; + } + + // Create a "group" bundle on the Custom Block entity type and turn it into + // a group. Note we're not using the Entity Test entity for this since it + // does not have real support for multiple bundles. + BlockContentType::create(['type' => 'group']); + Og::groupManager()->addGroup('block_content', 'group'); + + // Create a group. + $this->group = BlockContent::create([ + 'title' => $this->randomString(), + 'type' => 'group', + 'uid' => $this->users['owner']->id(), + ]); + $this->group->save(); + + // Create a group content type. + $type = NodeType::create([ + 'type' => 'group_content', + 'name' => $this->randomString(), + ]); + $type->save(); + $settings = [ + 'field_storage_config' => [ + 'settings' => [ + 'target_type' => 'block_content', + ], + ], + ]; + Og::createField(OgGroupAudienceHelper::DEFAULT_FIELD, 'node', 'group_content', $settings); + + // Grant both members and non-members permission to edit any group content. + foreach ([OgRoleInterface::AUTHENTICATED, OgRoleInterface::ANONYMOUS] as $role_name) { + $role_id = "block_content-group-$role_name"; + /** @var \Drupal\og\Entity\OgRole $role */ + $role = $this->container->get('entity_type.manager') + ->getStorage('og_role') + ->load($role_id); + $role->grantPermission('edit any group_content content'); + $role->save(); + } + + // Subscribe the normal member and the blocked member to the group. + foreach (['member', 'blocked'] as $membership_type) { + $state = $membership_type === 'member' ? OgMembershipInterface::STATE_ACTIVE : OgMembershipInterface::STATE_BLOCKED; + /** @var \Drupal\og\Entity\OgMembership $membership */ + $membership = OgMembership::create(); + $membership + ->setUser($this->users[$membership_type]) + ->setGroup($this->group) + ->addRole($role) + ->setState($state) + ->save(); + } + + // Create three group content items, one owned by the group owner, one by + // the member, and one by the blocked user. + foreach (['owner', 'member', 'blocked'] as $membership_type) { + $this->groupContent[$membership_type] = Node::create([ + 'title' => $this->randomString(), + 'type' => 'group_content', + 'uid' => $this->users[$membership_type]->id(), + OgGroupAudienceHelper::DEFAULT_FIELD => [['target_id' => $this->group->id()]], + ]); + $this->groupContent[$membership_type]->save(); + } + } + + /** + * Tests access to entity operations through the access hook. + * + * @param string $user + * The name of the user to test. + * @param array $expected_results + * An associative array indicating whether the user should have the right to + * edit content owned by the user represented by the array key. + * + * @dataProvider entityOperationAccessProvider + */ + public function testEntityOperationAccess($user, array $expected_results) { + foreach ($expected_results as $group_content => $expected_result) { + /** @var \Drupal\Core\Access\AccessResult $result */ + $result = og_entity_access($this->groupContent[$group_content], 'update', $this->users[$user]); + $this->assertEquals($expected_result, $result->isAllowed()); + } + } + + /** + * Data provider for ::testEntityOperationAccess(). + * + * @return array + * And array of test data sets. Each set consisting of: + * - The name of the user to test. + * - An associative array indicating whether the user should have the right + * to edit content owned by the user represented by the array key. + */ + public function entityOperationAccessProvider() { + return [ + [ + // Members should have the right to edit any group content. + 'member', + [ + 'owner' => TRUE, + 'member' => TRUE, + 'blocked' => TRUE, + ], + ], + [ + // Non-members should have the right to edit any group content. + 'non-member', + [ + 'owner' => TRUE, + 'member' => TRUE, + 'blocked' => TRUE, + ], + ], + [ + // Blocked members cannot edit any group content, not even their own. + 'blocked', + [ + 'owner' => FALSE, + 'member' => FALSE, + 'blocked' => FALSE, + ], + ], + ]; + } + +} diff --git a/tests/src/Kernel/Entity/OgRoleTest.php b/tests/src/Kernel/Entity/OgRoleTest.php index cb4dba019..533b756b2 100644 --- a/tests/src/Kernel/Entity/OgRoleTest.php +++ b/tests/src/Kernel/Entity/OgRoleTest.php @@ -7,7 +7,6 @@ use Drupal\KernelTests\KernelTestBase; use Drupal\og\Entity\OgRole; use Drupal\og\Exception\OgRoleException; -use Drupal\og\Og; /** * Test OG role creation. @@ -21,6 +20,13 @@ class OgRoleTest extends KernelTestBase { */ public static $modules = ['field', 'og']; + /** + * The entity storage handler for OgRole entities. + * + * @var \Drupal\Core\Entity\EntityStorageInterface + */ + protected $roleStorage; + /** * {@inheritdoc} */ @@ -29,6 +35,8 @@ protected function setUp() { // Installing needed schema. $this->installConfig(['og']); + + $this->roleStorage = $this->container->get('entity_type.manager')->getStorage('og_role'); } /** @@ -55,10 +63,10 @@ public function testRoleCreate() { ->setGroupBundle('group') ->save(); - $this->assertNotEmpty(OgRole::load('node-group-content_editor'), 'The role was created with the expected ID.'); - - $expected = Og::getRole('node', 'group', 'content_editor')->id(); - $this->assertEquals($expected, $og_role->id()); + /** @var \Drupal\og\Entity\OgRole $saved_role */ + $saved_role = $this->roleStorage->loadUnchanged('node-group-content_editor'); + $this->assertNotEmpty($saved_role, 'The role was created with the expected ID.'); + $this->assertEquals($og_role->id(), $saved_role->id()); // Checking creation of the role. $this->assertEquals($og_role->getPermissions(), ['administer group']); @@ -130,7 +138,7 @@ public function testRoleCreate() { ]); $og_role->save(); - $this->assertNotEmpty(OgRole::load('entity_test-group-configurator')); + $this->assertNotEmpty($this->roleStorage->loadUnchanged('entity_test-group-configurator')); // Check that we can retrieve the role name correctly. This was not // explicitly saved but it should be possible to derive this from the ID. diff --git a/tests/src/Unit/DefaultRoleEventTest.php b/tests/src/Unit/DefaultRoleEventTest.php index 29143f429..690ec33c0 100644 --- a/tests/src/Unit/DefaultRoleEventTest.php +++ b/tests/src/Unit/DefaultRoleEventTest.php @@ -538,7 +538,7 @@ protected function assertRoleEquals(OgRole $expected, OgRole $actual) { */ protected function expectOgRoleCreation(array &$roles) { foreach ($roles as &$properties) { - $role = new OgRole($properties, 'og_role'); + $role = new OgRole($properties); $properties = $role; } } diff --git a/tests/src/Unit/OgAccessEntityTest.php b/tests/src/Unit/OgAccessEntityTest.php index 12bdd27c3..4f2d31595 100644 --- a/tests/src/Unit/OgAccessEntityTest.php +++ b/tests/src/Unit/OgAccessEntityTest.php @@ -30,19 +30,6 @@ public function testAccessByOperation($operation) { $this->assertTrue($condition); } - /** - * Tests access to an un-saved entity. - * - * @coversDefaultmethod ::userAccessEntity - * @dataProvider permissionsProvider - */ - public function testEntityNew($operation) { - $group_entity = $this->groupEntity(); - $group_entity->isNew()->willReturn(TRUE); - $user_access = $this->ogAccess->userAccessEntity($operation, $group_entity->reveal(), $this->user->reveal()); - $this->assertTrue($user_access->isNeutral()); - } - /** * Tests getting a user's group entities. * diff --git a/tests/src/Unit/OgAccessEntityTestBase.php b/tests/src/Unit/OgAccessEntityTestBase.php index f3c39873c..026a02160 100644 --- a/tests/src/Unit/OgAccessEntityTestBase.php +++ b/tests/src/Unit/OgAccessEntityTestBase.php @@ -49,6 +49,14 @@ public function setup() { $this->groupContentEntity->isNew()->willReturn(FALSE); $this->groupContentEntity->getEntityType()->willReturn($entity_type->reveal()); $this->groupContentEntity->getEntityTypeId()->willReturn($entity_type_id); + $this->addCache($this->groupContentEntity); + + // It is expected that a list of entity operation permissions is retrieved + // from the permission manager so that the passed in permission can be + // checked against this list. Our permissions are not in the list, so it is + // of no importance what we return here, an empty array is sufficient. + $this->permissionManager->getDefaultEntityOperationPermissions($this->entityTypeId, $this->bundle, [$entity_type_id => [$bundle]]) + ->willReturn([]); // The group manager is expected to declare that this is not a group. $this->groupManager->isGroup($entity_type_id, $bundle)->willReturn(FALSE); diff --git a/tests/src/Unit/OgAccessTestBase.php b/tests/src/Unit/OgAccessTestBase.php index 3058bdf5d..09adc2be2 100644 --- a/tests/src/Unit/OgAccessTestBase.php +++ b/tests/src/Unit/OgAccessTestBase.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Session\AccountProxyInterface; @@ -14,6 +15,7 @@ use Drupal\og\GroupManager; use Drupal\og\OgAccess; use Drupal\og\OgMembershipInterface; +use Drupal\og\PermissionManager; use Drupal\user\EntityOwnerInterface; use Prophecy\Argument; @@ -36,6 +38,13 @@ class OgAccessTestBase extends UnitTestCase { */ protected $user; + /** + * The ID of the test group. + * + * @var string + */ + protected $groupId; + /** * The entity type ID of the test group. * @@ -64,6 +73,13 @@ class OgAccessTestBase extends UnitTestCase { */ protected $groupManager; + /** + * The mocked permission manager. + * + * @var \Drupal\og\PermissionManager|\Prophecy\Prophecy\ObjectProphecy + */ + protected $permissionManager; + /** * The OgAccess class, this is the system under test. * @@ -75,6 +91,7 @@ class OgAccessTestBase extends UnitTestCase { * {@inheritdoc} */ public function setUp() { + $this->groupId = $this->randomMachineName(); $this->entityTypeId = $this->randomMachineName(); $this->bundle = $this->randomMachineName(); @@ -126,9 +143,10 @@ public function setUp() { // Mock all dependencies for the system under test. $account_proxy = $this->prophesize(AccountProxyInterface::class); $module_handler = $this->prophesize(ModuleHandlerInterface::class); + $this->permissionManager = $this->prophesize(PermissionManager::class); // Instantiate the system under test. - $this->ogAccess = new OgAccess($config_factory->reveal(), $account_proxy->reveal(), $module_handler->reveal()); + $this->ogAccess = new OgAccess($config_factory->reveal(), $account_proxy->reveal(), $module_handler->reveal(), $this->groupManager->reveal(), $this->permissionManager->reveal()); // Set the Og::cache property values, to skip calculations. $values = []; @@ -192,15 +210,19 @@ public function setUp() { * The test group. */ protected function groupEntity($is_owner = FALSE) { + $entity_type = $this->prophesize(EntityTypeInterface::class); + $entity_type->id()->willReturn($this->entityTypeId); + $group_entity = $this->prophesize(EntityInterface::class); if ($is_owner) { $group_entity->willImplement(EntityOwnerInterface::class); // Our test user is hardcoded to have UID 2. $group_entity->getOwnerId()->willReturn(2); } + $group_entity->getEntityType()->willReturn($entity_type); $group_entity->getEntityTypeId()->willReturn($this->entityTypeId); $group_entity->bundle()->willReturn($this->bundle); - $group_entity->id()->willReturn($this->randomMachineName()); + $group_entity->id()->willReturn($this->groupId); return $this->addCache($group_entity); }