Drupal 10: Adding Custom Permissions To Groups

The Group module in Drupal is a powerful way of collecting together users and content under a single entity. These arbitrary collections of entities in a Drupal site can be used for editor teams within your site or company subscriptions where users can manage themselves, or anything that requires groups of users to be created.

During a recent project that used the Group module I found myself digging deeper into the Groups permissions system in order to achieve certain tasks. This is a similar permission system to that already created in Drupal, with the exception that the permission always forms a link between a Group and a user, and an optional additional entity. Permissions being local to the group is useful if you want to create groups of users that had access to pages and other entities that are kept within the group.

Group permissions are by no means simple though, and the different layers that exist within the permissions systems can make it difficult to see what is preventing access to a particular entity. This situation is complicated by the fact that much of the documentation and third party modules are built around Group version 2, with the current release of Group being version 3. For example, there is a documentation page on extending Groups access control, but as this is only for Groups version 2.0 it doesn't help with the latest version of Groups.

In this article I will look at how to create and use permissions within the Group module to grant users certain permissions if they are members of groups. Each example will get more complex as we go through the article and I will show how to use the permission within the site to control access.

First, it's useful if we take a quick look at how the permission levels in Groups work.

How Group Permission Levels Work

The Group permissions model works with the core Drupal permissions system, but augments it so that each Group type can have a different permissions model. This means that whilst a user might have a Drupal user role, what they are able to do with a Group (or items within the Group) is dependent on the permissions the user has within the Group and if they are a member or not.

The following is a hierarchy of the different levels of permissions that exist for a user and a group.

  • Drupal role - Your normal Drupal user role give you permissions to do certain things with the Groups system. Things like creating new groups is a Drupal based permission since there is no membership system yet.
  • An outsider role - This defines a person who does not have a group membership, but has a role of some kind in Drupal. You can configure Groups to allow certain roles to have more access to things inside the group entity. This is an optional role and must be configured to be active.
  • An insider role - This is a member of a group who also has a specific Drupal role. If a user has a membership within the group then their Drupal role can be used to give them extra permissions within the Group, even if their membership doesn't allow for this. Again, this is an optional role and must be configured to be active.
  • A membership - A user who is attached to a Group is given a membership. This membership has a specific set of permissions tied to it.
  • A membership with a role - It's also possible to create roles inside the Group, which gives users different levels of access to perform actions within the Group. Out of the box, Group will give you an "Admin" role, but you can add more to fine tune the permissions.

As you can see, there are a number of different permission levels to think of here, and you can get in a bit of a mess if you don't think things through properly. This simple overview is explained in some detail in the Group permission layers explained documentation page.

A common problem I see with Group setups is that a set of permissions are created that means that if a site administrator account joins a Group they get less permissions in the Group than they had if they weren't a member. This is because the insider role permission set was used for this role and the permissions for that role don't give them the same access as their outsider role.

When you set up a group you are given the option to map certain Drupal roles to permissions on the inside of the Group. Whilst this is really useful and often how you want a site to operate, it's crucial that you get this working correctly if you want to avoid access headaches.

With this in mind, let's look at how to create a simple Group permission.

Creating A Simple Group Permission

The simplest permission you can create for a Group is by using a x.group.permissions.yml file within a custom module. This will act just like normal Drupal permissions, except are only related to Groups.

As an example, the following snippet was created in a module called custom_group_permissions, and so the permissions file created was custom_group_permissions.group.permissions.yml.

edit group title:
  title: 'Edit group title'
  description: 'Gives members the ability to edit the group title.'

This will create a permission called "edit group title", which we have passed a title and description that will be used on the group permissions page. There are a lot more options available to group permissions, but this is the simplest thing you can do to get started.

After adding this file we can see the following options appear in the group permissions page for all group types.

A section of the Group permissions page, showing that certain users can be given the ability to edit group titles.

Here, we are only giving the ability to edit group titles to the administrators of the group and users with the administrator role (both external and internal to the group).

To make this permission do something we need to create a hook that will alter the way that the group edit form works. Each Group entity has a method called hasPermission() that is used to check the permission of a user against a Group.

The following code implements the hook_form_FORM_ID_alter() hook and targets the "group_activity_edit_form", which is used to edit Groups of the type "Activity".

/**
 * Implements hook_form_FORM_ID_alter().
 */
function custom_group_permissions_form_group_activity_edit_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
  // Alters the group_edit_form form for the "Activity" group type.
  $group = $form_state->getFormObject()->getEntity();
  if ($group instanceof \Drupal\group\Entity\Group) {
    $account = \Drupal::currentUser();
    if ($group->hasPermission('edit group title', $account) === FALSE) {
      $form['label']['widget']['#disabled'] = TRUE;
    }
  }
}

With this code in place, assuming that we also give this user the ability to update the Group itself, then they must also be given the permission "edit group title" to be able to change the title of the group (called "label" internally). You can also see the permission for this role when you export the configuration for this group.

Permissions Arguments

We have already seen the title and description arguments for the Group permissions in the Group permissions YAML file, but what other permissions attributes are available to us? The interface \Drupal\group\Access\GroupPermissionHandlerInterface in the Group module defines this list as the following.

  • title : The untranslated human-readable name of the permission, to be shown on the permission administration page. You may use placeholders as you would in t().
  • title_args : (optional) The placeholder values for the title.
  • description : (optional) An untranslated description of what the permission does. You may use placeholders as you would in t().
  • description_args : (optional) The placeholder values for the description.
  • restrict access : (optional) A boolean which can be set to TRUE to indicate that site administrators should restrict access to this permission to trusted users. This should be used for permissions that have inherent security risks across a variety of potential use cases. When set to TRUE, a standard warning message will be displayed with the permission on the permission administration page. Defaults to FALSE.
  • warning : (optional) An untranslated warning message to display for this permission on the permission administration page. This warning overrides the automatic warning generated by 'restrict access' being set to TRUE. This should rarely be used, since it is important for all permissions to have a clear, consistent security warning that is the same across the site. Use the 'description' key instead to provide any information that is specific to the permission you are defining. You may use placeholders as you would in t().
  • warning_args : (optional) The placeholder values for the warning.
  • allowed for : (optional) An array of strings that define which membership types can use this permission. Possible values are: 'anonymous', 'outsider', 'member'. Will default to all three when left empty.
  • provider : (optional) The provider name of the permission. Defaults to the module providing the permission. You may set this to another module's name to make it appear as if the permission was provided by that module.
  • section : (optional) The untranslated section name of the permission. This is used to maintain a clear overview on the permissions form. Defaults to the plugin name for plugin provided permissions and to "General" for all other permissions.
  • section_args : (optional) The placeholder values for the section name.
  • section_id : (optional) The machine name to identify the section by, defaults to the plugin ID for plugin provided permissions and to "general" for all other permissions.

Notice that all of the strings you pass here are untranslated string, as they are accompanied by a "_args" property that allows you to pass arguments into the translation of the string. For example, the "title" property is updated like this when being prepared for display in the Group permissions section.

$permission['title'] = $this->t($permission['title'], $permission['title_args']);

To define this in the YAML file you would do something like this.

edit group title:
  title: 'Edit group @arg'
  title_args: {
    '@arg': 'title'
  }
  description: 'Gives members the ability to edit the group title.'

The same thing happens for other parts of this permissions array that take a argument for the string.

Group Permissions On Routes

Instead of adding a hook and checking permissions directly, you can also add the requirement of _group_permission and _group_member to a route definition. The following snippet shows the use of these requirements in a route definition.

custom_group_permissions.example:
  path: '/group_reports/{group}'
  defaults:
    _title: 'Group Report'
    _controller: '\Drupal\custom_group_permissions\Controller\ReportController::report'
  requirements:
    _permission: 'administer content'
    _group_permission: 'access group reports page'
    _group_member: 'TRUE'

With this in place the Drupal will detect these requirements and add the following permission checks for this route.

  • The _permission requirement is a standard Drupal permission check, which means that we are first checking to see if this user has the "administer content" permission in order to view this content.
  • The _group_permission requirement ensures that the user has the Group permission of "access group reports page". Remember that with insider and outsider permissions this permission doesn't necessarily mean that this person is a group member.
  • The _group_member requirement ensures that the user is a member of the currently loaded group, passed to the route through the "{group}" parameter in the URL.

If any of these permission checks returns an forbidden permission check then the route not be shown to the user. The order of the checks here is important as it means that we allow wide ranging permissions first and then narrow the permission checks as we go through. In the above code we first run the _permission check, followed by _group_permission, and finally _group_member.

Alternatively, if you want to add Group permissions a pre-existing route then you can inject these parameters into the route using a route subscriber.

Creating Dynamic Group Permissions

Defining flat permissions is perfectly fine, but to create a dynamic set of permissions we need to use permission callbacks to define the permissions.

To do this we first need to inform the Group module about our dynamic permission callback class. This is done using a permission_callbacks flag in the x.group.permissions.yml file where we defined a number of different static methods in classes that define permissions.

For example, to redefine the edit group title permission as a callback we change the definition in the custom_group_permissions.group.permissions.yml file to the following.

permission_callbacks:
  - '\Drupal\custom_group_permissions\Access\CustomGroupPermissions::groupPermissions'

The CustomGroupPermissions defined above is class that contains a single method called groupPermissions(), which the Group module will call automatically when finding for permissions. This method just needs to return an array representation of the permission.

Here is the class in full, which just replicates our "edit group title" permission from earlier.

<?php

namespace Drupal\custom_group_permissions\Access;

/**
 * Provides dynamic permissions for groups of different types.
 */
class CustomGroupPermissions {

  /**
   * Returns an array of group type permissions.
   *
   * @return array
   *   The group permissions.
   */
  public function groupPermissions() {
    $perms = [];

    $perms['edit group title'] = [
      'title' => 'Edit group title',
      'description' => 'Gives members the ability to edit the group title.',
    ];

    return $perms;
  }

}

With this code in place the permissions we have available in the Group haven't changed, the only difference is that we are generating them dynamically. The real power of this technique comes from generating dynamic permissions around the entities and other data you have on your site.

As an example, let's expand this list of permissions to include all of the base fields that come with each Group type.

In order to do this we will need to inject the entity_field.manager service into the object. Drupal will check the class definition as it is instantiated and if it extends \Drupal\Core\DependencyInjection\ContainerInjectionInterface then it will call the create() method to generate the object, which allows us to inject our needed dependencies into the object.

Once that is in place, it is just a case of using the groupPermissions() method to return a permission for each of the core fields that a user might be able to change in the group definition. Here is the updated code.

<?php

namespace Drupal\custom_group_permissions\Access;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Provides dynamic permissions for groups of different types.
 */
class CustomGroupPermissions implements ContainerInjectionInterface {

  /**
   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
   */
  protected $entityFieldManager;

  /**
   * {@inheritDoc}
   */
  public static function create(ContainerInterface $container) {
    $instance = new static();
    $instance->setEntityFieldManager($container->get('entity_field.manager'));
    return $instance;
  }

  /**
   * Sets the entity field manager service.
   *
   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager
   *   The entity field manager service.
   *
   * @return self
   *   The current object.
   */
  public function setEntityFieldManager(EntityFieldManagerInterface $entityFieldManager): self {
    $this->entityFieldManager = $entityFieldManager;
    return $this;
  }

  /**
   * Returns an array of group type permissions.
   *
   * @return array
   *   The group permissions.
   */
  public function groupPermissions() {
    $perms = [];

    foreach ($this->entityFieldManager->getBaseFieldDefinitions('group') as $field => $definition) {
      if ($definition['read-only'] === TRUE) {
        continue;
      }
      $perms['edit group ' . $field] = [
        'title' => 'Edit group @fieldname',
        'title_args' => [
          '@fieldname' => $definition['label'],
        ]
      ];
    }

    return $perms;
  }

}

With this in place, our permissions list in the Group interface is now expanded to include all the base fields for the Group. Of course, we still need to code the permission checks into the form edit hook, but we now have the permissions in place.

Using this system we can create permissions for things like content, taxonomy, workflow or anything else outside of Groups that we might be interested in. There is a limitation as we can't define these permissions for different types of Group since the permissions callback accepts no arguments.

When dealing with entities it might be better to think about using the Group relationship plugins to get the job done.

Adding Entity Type Permissions To Groups

Although permission callbacks are able to create dynamic permissions, they are limited by the fact that they have no knowledge of the Group they are acting upon. This means that they apply to all group types, even if you have no intention of using them.

The solution to this is to create a Group plugin, which can be loaded into a Group and used to dynamically determine the permissions for the group. Group plugins are used to create relationships between Group entities and other entities in your site, but we can use certain parts of this relationship builder to give us a pluggable permissions system. This can augment the existing permissions on a site by applying them to the Group level. To do this we need to create a GroupRelationType plugin, which must live in the src/Plugin/Group/Relation directory inside your custom module.

The following is a typical GroupRelationType plugin class, which extends the \Drupal\group\Plugin\Group\Relation\GroupRelationBase class. This base class provides all of the functionality we might need for the plugin to work with the Group module and so is useful to extend it.

Here is an example GroupRelationType plugin that will be used to control access to the "user" entity attached to this Group.

<?php

namespace Drupal\custom_group_permissions\Plugin\Group\Relation;

use Drupal\group\Plugin\Group\Relation\GroupRelationBase;

/**
 * Provides a group relation for User entities.
 *
 * @GroupRelationType(
 *   id = "custom_group_permissions",
 *   label = @Translation("Group Permissions"),
 *   description = @Translation("Adds permissions to the group."),
 *   entity_type_id = "user",
 *   entity_access = TRUE
 * )
 */
class PermissionRelation extends GroupRelationBase {

}

Note that this relationship is already being done via the membership plugin, we are just adding to these permissions with our own set of permissions and access rules. It is possible to create relationship plugins without actively forming the relationship between the Group and the entity. Adding the full relationship is certainly possible, but is slightly beyond the scope of this article.

With this in place you can now activate it in the Group Content tab inside your custom group type setup at the path "admin/group/types/manage/<group>/content". You should see the following at the bottom of the screen.

The Group permissions plugin in the group available content page.

Clicking "Install" will allow the plugin to work with the Group type you are currently looking at.

To use this plugin for permissions we must create a service that the Group module will pick up and use to populate the required permissions.

The service name must follow the following format:

group.relation_handler.$handler_type.$group_relation_type_id

This means that we must call our service "group.relation_handler.permission_provider.custom_group_permissions", as it is the "permission_provider" for the "custom_group_permissions" relation plugin. The service definition for the above class would be as follows.

services:
  group.relation_handler.permission_provider.custom_group_permissions:
    class: 'Drupal\group\Plugin\Group\RelationHandlerDefault\PermissionProvider'
    arguments: ['@entity_type.manager', '@group_relation_type.manager']
    shared: false

As we want to create a permission provider the service class we create must implement the interface \Drupal\group\Plugin\Group\RelationHandler\PermissionProviderInterface. If we attempt to create a permission provider without implementing this interface then Group will throw an error. Group also comes with a trait called \Drupal\group\Plugin\Group\RelationHandler\PermissionProviderTrait that implements all of the required methods for this interface, which allows us to just write the code we need to get our permissions working.

The key part of this setup is that we inject the "group.relation_handler.permission_provider" service into the object, which we then set as the parent property within the class. This parent property is an object of \Drupal\group\Plugin\Group\RelationHandlerDefault\PermissionProvider that is used to fill in the gaps around the permission providers. Without this in place you'll find Groups throwing a few errors with regards to the parent being missing.

The custom permissions we create using this method are all based around operations that might be done on an entity of some kind. In our case, because we stipulated that the entity type we are working with is the User entity, the Group module will ask our service for permissions for view, update, delete and create operations for this entity. Group will go through the available operation for the entity and call the getPermission() method for each operation in turn, as well as defining the scope of this permission (which is either "own", or "any"). The target of the permission is either the entity itself or the relation created when that entity is added to the Group.

In the following example we are setting the single permission of "view custom_group_permissions entity" for our module. This will translate to the User entity in the permissions setup.

<?php

namespace Drupal\custom_group_permissions\Plugin\Group\RelationHandler;

use Drupal\group\Plugin\Group\RelationHandler\PermissionProviderInterface;
use Drupal\group\Plugin\Group\RelationHandler\PermissionProviderTrait;

/**
 * Provides group permissions for the custom_group_permissions relation plugin.
 */
class CustomGroupPermissionsProvider implements PermissionProviderInterface {
  use PermissionProviderTrait;

  /**
   * Constructs a new GroupMembershipPermissionProvider.
   *
   * @param \Drupal\group\Plugin\Group\RelationHandler\PermissionProviderInterface $parent
   *   The parent permission provider.
   */
  public function __construct(PermissionProviderInterface $parent) {
    $this->parent = $parent;
  }

  /**
   * {@inheritdoc}
   */
  public function getPermission($operation, $target, $scope = 'any') {
    if ($operation === 'view' && $target === 'entity' && $scope === 'any') {
      return "$operation $this->pluginId $target";
    }
  }

}

With this in place, this permission will appear in the Group permissions page and be available as a permission within the group.

It should be noted that the permissions must be to do with the operations you would perform on an entity, which means you can't return arbitrary permissions here (like "edit group title") as they will be ignored by the Group permissions system.

This permission doesn't actually do anything on it's own so we now need to implement an access check. The first thing we need to create is an access_controller plugin that will be used to perform the permission check.

  group.relation_handler.access_control.custom_group_permissions:
    class: 'Drupal\custom_group_permissions\Plugin\Group\RelationHandler\CustomGroupAccessControl'
    arguments: ['@group.relation_handler.access_control']
    shared: false

The CustomGroupAccessControl plugin class is similar to the CustomGroupPermissionProvider class, but in this case we are extending the \Drupal\group\Plugin\Group\RelationHandler\AccessControlInterface that Group provides. There is also a handy \Drupal\group\Plugin\Group\RelationHandler\AccessControlTrait that we can use to fill in any gaps that the plugin has. The parent property of "group.relation_handler.access_control" is passed to the service, which is an object of \Drupal\group\Plugin\Group\RelationHandlerDefault\AccessControl that we assign to the parent property in the class.

The CustomGroupAccessControl class we create here has quite a bit of complexity, although most of that is involved with finding the Group that relates to the entity passed to it and checking the permission of that group. We are also making sure that the user has permissions to perform the operation on the entity if they are the author of that entity (and they have the appropriate permissions).

<?php

namespace Drupal\custom_group_permissions\Plugin\Group\RelationHandler;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultNeutral;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\group\Plugin\Group\RelationHandler\AccessControlInterface;
use Drupal\group\Plugin\Group\RelationHandler\AccessControlTrait;
use Drupal\user\EntityOwnerInterface;

/**
 * Provides group permissions for the custom_group_permissions relation plugin.
 */
class CustomGroupAccessControl implements AccessControlInterface {
  use AccessControlTrait;

  /**
   * Constructs a new CustomGroupAccessControl.
   *
   * @param \Drupal\group\Plugin\Group\RelationHandler\AccessControlInterface $parent
   *   The parent access control handler.
   */
  public function __construct(AccessControlInterface $parent) {
    $this->parent = $parent;
  }

  /**
   * {@inheritdoc}
   */
  public function entityAccess(EntityInterface $entity, $operation, AccountInterface $account, $return_as_object = FALSE) {
    // Assume we will return a neutral permission check by default.
    $access = AccessResultNeutral::neutral();

    if ($this->supportsOperation($operation, 'entity') === FALSE) {
      return $access;
    }

    $storage = $this->entityTypeManager->getStorage('group_relationship');
    $groupRelationships = $storage->loadByEntity($entity);
    if (empty($groupRelationships)) {
      // If the entity does not belong to any group, we have nothing to say.
      return $access;
    }

    /** @var \Drupal\group\Entity\GroupRelationship $groupRelationship */
    foreach ($groupRelationships as $groupRelationship) {
      $group = $groupRelationship->getGroup();
      $access = AccessResult::allowedIf($group->hasPermission("$operation $this->pluginId entity", $account));

      $owner_access = $access->orIf(AccessResult::allowedIf(
        $group->hasPermission("$operation $this->pluginId entity", $account)
        && $group->hasPermission("$operation own $this->pluginId entity", $account)
        && $entity instanceof EntityOwnerInterface
        && $entity->getOwnerId() === $account->id()
      ));

      $access = $access->orIf($owner_access);

      $access->addCacheableDependency($groupRelationship);
      $access->addCacheableDependency($group);
    }

    return $access;
  }

}

This code doesn't do anything on its own, it first needs to be called from an access check situation.

To do this we need to intercept the access check using a implementation of the hook_entity_access() hook. In this hook we check for plugins that are connected to the entity type we have in hand (which in our case is User) and then load the above access control service using the "group_relation_type.manager" service. We need to load the access control service in this way as the Group module will populate the object with a lot of useful items that otherwise wouldn't exist if we just created the service on its own.

Here is the implementation of the hook_entity_access() hook.

/**
 * Implements hook_entity_access().
 */
function custom_group_permissions_entity_access(\Drupal\Core\Entity\EntityInterface $entity, $operation, \Drupal\Core\Session\AccountInterface $account) {
  if ($entity->isNew()) {
    return \Drupal\Core\Access\AccessResult::neutral();
  }
  /** @var \Drupal\group\Plugin\Group\Relation\GroupRelationTypeManagerInterface $groupRelationTypeManager */
  $groupRelationTypeManager = \Drupal::service('group_relation_type.manager');

  // Find all the group relations that define access to this entity.
  $plugin_ids = $groupRelationTypeManager->getPluginIdsByEntityTypeAccess($entity->getEntityTypeId());
  if (empty($plugin_ids)) {
    return \Drupal\Core\Access\AccessResult::neutral();
  }
  
  foreach ($plugin_ids as $plugin) {
    // Attempt to load each plugin service and check for the entity access.
    $service = "group.relation_handler.access_control.$plugin";
    if (\Drupal::hasService($service) === TRUE) {
      $pluginObject = $groupRelationTypeManager->createHandlerInstance($plugin, 'access_control');
      return $pluginObject->entityAccess($entity, $operation, $account);
    }
  }
}

Now, when a user visits the user profile page of another user this access check will trigger and either allow or deny them based on the permission setup in Drupal and the Group itself.

Whilst this works, remember that we are using an existing relationship plugin (i.e. the members of a Group, also known as users) to perform our own permission checks that augment the existing permissions that the Group has. This is an important consideration as if you want to test access for something that isn't part of a Group relationship you will need to fully implement some of the other code involved with relationships. Once that code is in place you can add those entities to your groups and perform permission checks on them in much the same way as we have done here.

Permission Decorators

Finally, it's worth talking a little bit about permission decorators. This is where we take a pre-existing permission check class and "decorate" it so that it understands Groups and can therefore perform Group level permission checks on entities. This technique can be quite complex but I'm adding this in for completeness since it has a good use case of allowing any permission to be applied to a Group, as long as there is a relationship between the Group and the permission.

The first thing we need to do is pick an access_check service that we want to decorate. Since we are dealing with User entities we need to decorate the access_check.entity service as that gives us the access check we need. This service has the following definition.

  access_check.entity:
    class: Drupal\Core\Entity\EntityAccessCheck
    tags:
      - { name: access_check, applies_to: _entity_access }

To decorate this we need to create another service definition and add the argument "decorates" with the name of the service we want to decorate. We also pass in additional arguments that allow us to use other services within this new service.

  custom_group_permissions.entity:
    class: 'Drupal\custom_group_permissions\Access\CustomGroupUserPermissions'
    arguments: ['@entity_type.manager', '@group_relation_type.manager']
    decorates: access_check.entity

With this in place we can now create the CustomGroupUserPermissions class. this class does pretty much the same task as the CustomGroupAccessControl class we defined earlier in that we perform some access checks on the entity and group. The main difference here is that we need to load the entity from the route and use a similar permission check to find out if the user has permission within any of their attached groups.

Here is the full source code of the CustomGroupUserPermissions class.

<?php

namespace Drupal\custom_group_permissions\Access;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultNeutral;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityAccessCheck;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\group\Plugin\Group\Relation\GroupRelationTypeManagerInterface;
use Drupal\user\EntityOwnerInterface;
use Symfony\Component\Routing\Route;

/**
 * Performs a permission check on user entities in Groups.
 */
class CustomGroupUserPermissions extends EntityAccessCheck {

  /**
   * The entity type manager service.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The group content enabler plugin manager.
   *
   * @var \Drupal\group\Plugin\Group\Relation\GroupRelationTypeManagerInterface
   */
  protected GroupRelationTypeManagerInterface $groupRelationTypeManager;

  /**
   * Constructs the group latest revision check object.
   *
   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
   *   The moderation information service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager service.
   * @param \Drupal\group\Plugin\Group\Relation\GroupRelationTypeManagerInterface $group_relation_type_manager
   *   The group content enabler plugin manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, GroupRelationTypeManagerInterface $group_relation_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
    $this->groupRelationTypeManager = $group_relation_type_manager;
  }

  /**
   * {@inheritDoc}
   */
  public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account) {
    $access = parent::access($route, $route_match, $account);
    if (!$access->isAllowed()) {
      // Load the entity from the route.
      $requirement = $route->getRequirement('_entity_access');
      [$entity_type, $operation] = explode('.', $requirement);

      $parameters = $route_match->getParameters();
      if ($parameters->has($entity_type)) {
        $entity = $parameters->get($entity_type);
        if ($entity instanceof EntityInterface) {
          // Get the specific group access for this entity.
          $group_access = $this->checkGroupAccess($entity, $operation, $account);
          // Combine the group access with the upstream access.
          $access = $access->orIf($group_access);
        }
      }
    }

    return $access;
  }

  /**
   * Determine group-specific access to an entity.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity to check.
   * @param string $operation
   *   The operation to check access for.
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The user to check access for.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   Returns allowed access if the entity belongs to a group, and the user
   *   has both the 'view custom_group_permissions entity' and the
   *   'view own custom_group_permissions entity' permission in a group it
   *   belongs to.
   */
  protected function checkGroupAccess(ContentEntityInterface $entity, $operation, AccountInterface $account) {
    // Assume we will return a neutral permission check by default.
    $access = AccessResultNeutral::neutral();

    $storage = $this->entityTypeManager->getStorage('group_relationship');
    $groupRelationships = $storage->loadByEntity($entity);
    if (empty($groupRelationships)) {
      // If the entity does not belong to any group, we have nothing to say.
      return $access;
    }

    /** @var \Drupal\group\Entity\GroupRelationship $groupRelationship */
    foreach ($groupRelationships as $groupRelationship) {
      $group = $groupRelationship->getGroup();
      $access = AccessResult::allowedIf($group->hasPermission("$operation custom_group_permissions entity", $account));

      $owner_access = $access->orIf(AccessResult::allowedIf(
        $group->hasPermission("$operation custom_group_permissions entity", $account)
        && $group->hasPermission("$operation own custom_group_permissions entity", $account)
        && $entity instanceof EntityOwnerInterface
        && $entity->getOwnerId() === $account->id()
      ));

      $access = $access->orIf($owner_access);

      $access->addCacheableDependency($groupRelationship);
      $access->addCacheableDependency($group);
    }

    return $access;
  }

}

With these two elements in place it is possible to perform a permission check on any entity that has been added to a Group through a relationship. The Group relationship to the entity is still required for this permission check to work but it makes sense that Groups would control permissions to entities like this.

If you want to see this technique in action then it can be found in use within the Group Content Moderation module that is used to control access to revisions of entities within a Group. The module uses a slightly different technique that involves service providers to decorate the needed services so that the decoration will only happen if the content moderation module is active. I should note that it only supports Group version 2.0, although there are only minor changes required to update it to Groups version 3.0.

Conclusion

Adding permissions to Groups can be quite complex, but most of that complexity is ensuring that the access checks you want to run are performed in the appropriate place. You can create Group permissions statically, dynamically, or through a plugin permissions handler and once you have the access code working they become a useful part of your Group functionality.

It is also crucial that you understand how outsider and insider permissions work and how they can cause users to degrade in their access to groups if they join. The first hint that you've got something wrong here is when groups start disappearing from the Group entity listing page, but it can be more subtle, like losing member administration access.

Group is a powerful module and allows for all sorts of functionality to be created by joining Users with different types of entities. I have found that most of the complexity of Groups comes from ensuring that users have access to the right entities within Groups, whether they are a member of the Group or not. 

I haven't gone into creating custom relationships in Groups (other than augmenting the permissions of existing relationships) but I may follow up this article with another one detailing the process involved in that.

If you want to see this code together in a single place then I've created a github repo for the Custom Group Permissions module. Please don't install this module in production though, it's only intended to demonstrate all the elements described in this article.

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
11 + 9 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.