Skip to content
This repository has been archived by the owner on Aug 18, 2024. It is now read-only.

Latest commit

 

History

History
372 lines (294 loc) · 15.7 KB

access.md

File metadata and controls

372 lines (294 loc) · 15.7 KB

Access control for groups and group content

Controlling access to groups and group content is one of the most important aspects of building a group based project. Having separate access rules for different groups and the content in them is more complex than the standard role and permission based access system built into Drupal core so Organic Groups (OG) has extended the core functionality to make it more flexible and, indeed, organic for developers to design their access control systems.

Group level permissions

The first line of defense is group level access control. Group level permissions apply to the group as a whole. OG ships with a number of permissions that control basic group and membership management tasks such as:

  • Administration permissions such as update group, delete group, manage members and approve and deny subscription.
  • Membership permissions such as subscribe and subscribe without approval which can be granted to non-members.

Developers can define their own group level permissions by implementing an event listener that subscribes to the PermissionEventInterface::EVENT_NAME event and instantiating GroupPermission objects with the properties of the permission.

As an example let's define a permission that would allow an administrator to make a group private or public - this could be used in a project that has private groups that would only accept new members that are invited by existing members:

<?php
// phpcs:ignoreFile
namespace Drupal\my_module\EventSubscriber;

use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\og\Event\PermissionEventInterface;
use Drupal\og\GroupPermission;
use Drupal\og\OgRoleInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class OgEventSubscriber implements EventSubscriberInterface {

  use StringTranslationTrait;

  public static function getSubscribedEvents() {
    return [
      PermissionEventInterface::EVENT_NAME => [['provideGroupLevelPermissions']],
    ];
  }

  public function provideGroupLevelPermissions(PermissionEventInterface $event): void {
    $event->setPermission(
      new GroupPermission([
        // The unique machine name of the permission, similar to a Drupal core
        // permission.
        'name' => 'set group privacy',
        // The human readable permission title, to show to site builders in the UI.
        'title' => $this->t('Set group privacy'),
        // An optional description providing extra information.
        'description' => $this->t('Users can only join a private group when invited by an existing member.'),
        // The roles to which this permission applies by default when a new
        // group is created.
        'default roles' => [OgRoleInterface::ADMINISTRATOR],
        // Whether or not this permission has security implications and should
        // be restricted to trusted users only.
        'restrict access' => TRUE,
      ])
    );
  }

}

The list of group level permissions that are provided out of the box by OG can be found in \Drupal\og\EventSubscriber\OgEventSubscriber::provideDefaultOgPermissions().

Group content entity operation permissions

In addition to group level permissions we also need to control access to CRUD operations on group content entities. Depending on the group requirements the creation, updating and deletion of group content is restricted to certain roles within the group.

Drupal core defines the entity CRUD operations as simple strings (e.g. for an 'article' node type we would have the permission delete own article content).

OG not only supports nodes but all kinds of entities, and the simple string based permissions from Drupal core have proved to be difficult to use for operations that need to be applicable across multiple entity types. Some typical use cases are that a group administrator should be able to edit and delete all group content, regardless of entity type. Also a normal member of a group might have the permission to edit their own content, but not the content of other members.

Drupal core's permission system doesn't lend itself well to these use cases since we cannot reliably derive the entity type and operation from these simple string based permissions. For example the permission delete own article content gives a user the permission to delete nodes of type 'article' which were created by themselves. This permission string contains the words 'delete' and 'own' so it would be possible to come up with an algorithm that infers the operation and the scope, but the bundle and entity type can not be derived easily. In fact the entity type 'node' doesn't even appear in the string!

OG solves this by defining group content entity operation (CRUD) permissions using a structured object: GroupContentOperationPermission. The above permission would be defined as follows, which encodes all the data we need to determine the entity type, bundle, operation, ownership (whether or not a user is performing the operation on their own entity), etc:

$permission = new \Drupal\og\GroupContentOperationPermission([
  'entity_type' => 'node',
  'bundle' => 'article',
  'name' => "delete own article content",
  'title' => $this->t('%type_name: Delete own content', ['type_name' => 'article']),
  'operation' => 'delete',
  'owner' => TRUE,
]);

These permissions are defined in the same way as the group level permissions, in an event listener for the og.permissions event. Here are some examples how OG sets these permissions:

  • OgEventSubscriber::getDefaultEntityOperationPermissions(): creates a generic set of permissions for every group content type. These can be overridden by custom modules as shown below.
  • OgEventSubscriber::provideDefaultNodePermissions(): an example of how the generic permissions can be overridden. This ensures that we can use the same permission names for the Node entity type in our group content as the ones used by core.

Granting permissions to users

Similar to Drupal core, permissions are not directly assigned to users in OG, but they are assigned to roles, and a user can have one or more roles in a group.

The role data is stored in a config entity type named OgRole, and whenever a new group type is created, the following roles will automatically be created:

  • member and non-member: these two roles are required for every group, they indicate whether or not a user is a member.
  • administrator: this role is not strictly required but is created by default by OG because it is considered to be generally useful for most groups.
  • Developers can choose to provide additional default roles by listening to the og.default_role event.

Whenever a default role is created, it will automatically inherit the permissions that are assigned to the role in their permission declaration (see the default roles property above which takes an array of roles to which these permissions should be applied).

To manually assign a permission to a role for a certain group, it can be done as follows:

// OgRole::loadByGroupAndName() is the easiest way to load a specific role for
// a given group.
$admin_role = OgRole::loadByGroupAndName($this->group, OgRoleInterface::ADMINISTRATOR);
$admin_role->grantPermission('my permission')->save();

Similarly, an existing permission can be removed from a role by calling $admin_role->revokePermission('my permission') and saving the OgRole entity.

For more information on how OgRole objects are handled, check the following methods:

  • \Drupal\og\GroupTypeManager::addGroup(): this code is responsible for creating a new group type and will create the roles as part of it.
  • \Drupal\og\OgRoleManager::getDefaultRoles(): creates the default roles and fires the event listener that modules can hook into the provide additional default roles.
  • \Drupal\og\EventSubscriber\OgEventSubscriber::provideDefaultRoles(): OG's own implementation of the event listener, which provides the default administrator role.

Checking if a user has a certain permission in a group

It would be natural to think that checking a permission would be a simple matter of loading the OgRole entity and calling $role->hasPermission() on it, but this is not sufficient. There are a number of additional things to consider:

  • The super user (user ID 1) has full permissions.
  • OG has a configuration option to allow full access to group owners.
  • Users that have the global permission administer organic groups have all permissions in all groups.
  • The role can have the is_admin flag set which will grant all permissions.
  • Modules can alter permissions depending on their own requirements.

OG provides a service that will perform these checks. To determine whether the currently logged in user has for example the manage members permission on a given group entity:

// Load some custom group.
$group = \Drupal::entityTypeManager()->getStorage('my_group_type')->load($some_id);

/** @var \Drupal\og\OgAccessInterface $og_access */
$og_access = \Drupal::service('og.access');
$access_result = $og_access->userAccess($group, 'manage members');

// An AccessResult object is returned including full cacheability metadata. In
// order to get the access as a simple boolean value, call `::isAllowed()`.
if ($access_result->isAllowed()) {
  // The user has permission.
}

For projects that have a large number of group types and group content types there is also a convenient method ::userAccessEntity() to discover if a user has a group level permission on any given entity, being a group, group content, or even something that is not related to any group:

// Load some entity, which might be a group, group content, or something not
// related to any group.
$entity = \Drupal::entityTypeManager()->getStorage('my_entity_type')->load($some_id);

/** @var \Drupal\og\OgAccessInterface $og_access */
$og_access = \Drupal::service('og.access');
$access_result = $og_access->userAccessEntity('manage members', $entity);

// An AccessResult object is returned including full cacheability metadata. In
// order to get the access as a simple boolean value, call `::isAllowed()`.
if ($access_result->isAllowed()) {
  // The user has permission.
}

Caution: The above example will do a discovery to find out if the passed in entity is a group or group content, and will loop over all associated groups to determine access. While this is very convenient this also comes with a performance impact, so it is recommended to use it only in cases where the faster ::userAccess() is not applicable.

Checking if a user can perform an entity operation on group content

OG extends Drupal core's entity access control system so checking access on an entity operation is as simple as this:

// Check if the given user can edit the entity, which is a group content entity.
$access_result = $group_content_entity->access('update', $user);

Behind the scenes, OG implements hook_access() and delegates the access check to the OgAccess service, so within the context of group content this is equivalent to calling the following:

/** @var \Drupal\og\OgAccessInterface $og_access */
$og_access = \Drupal::service('og.access');
$access_result = $og_access->userAccessEntityOperation('update', $group_content_entity, $user);

There is also a faster way to get the same result, in case you know beforehand to which group the group content entity belongs. The following example is more efficient since it doesn't need to do an expensive discovery of the groups to which the entity belongs:

// In case we know the group entity we can use the faster method:
$group_entity = \Drupal::entityTypeManager()->getStorage('my_group_type')->load($some_id);

/** @var \Drupal\og\OgAccessInterface $og_access */
$og_access = \Drupal::service('og.access');
$access_result = $og_access->userAccessGroupContentEntityOperation('update', $group_entity, $group_content_entity, $user);

Altering permissions

There are many use cases where permissions should be altered under some circumstances to fulfill business requirements. OG offers ways for modules to hook into the permission system and alter the access result.

Alter group permissions

Modules can implement hook_og_user_access_alter() to alter group level permissions. Here is an example that implements a use case where groups can only be deleted if they are unpublished. This functionality can be toggled off by site administrators in the site configuration, so the example also demonstrates how to alter the cacheability metadata to include the config setting. The access result is different if this option is turned on or off, so this needs to be included in the cache metadata.

function mymodule_og_user_access_alter(array &$permissions, CacheableMetadata $cacheable_metadata, array $context): void {
  // Retrieve the module configuration.
  $config = \Drupal::config('mymodule.settings');

  // Check if the site is configured to allow deletion of published groups.
  $published_groups_can_be_deleted = $config->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);
}

Alter group content permissions

In addition to altering group level permissions, OG also allows to alter access to group content entity operations, this time using an event listener.

<?php

namespace Drupal\my_module\EventSubscriber;

use Drupal\og\Event\GroupContentEntityOperationAccessEventInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class MyModuleEventSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents() {
    return [
      GroupContentEntityOperationAccessEventInterface::EVENT_NAME => [['moderatorsCanManageComments']],
    ];
  }

  public function moderatorsCanManageComments(GroupContentEntityOperationAccessEventInterface $event): void {
    $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();
    }
  }

}

Please note that this follows the same principles of the Drupal core entity access handlers. Access will be granted only if at least one of the subscribers or other properties grants access (like having the administer organic groups permission). If any event listener denies access, then this will be considered as a hard deny, and cannot be overruled. This might have some unexpected consequences; for example if group content is published in multiple groups, and a user has access to a permission in all groups, except one in which access is forbidden, then OgAccess::userAccessEntityOperation() will return access denied.

In most cases this can be solved by only granting access to a permission when required, and remaining neutral if not. Alternatively check access to individual groups using OgAccess::userAccessGroupContentEntityOperation() - this will only return access denied for the specific group where access has been forbidden, while still allowing access for all others.