Extending Group v1's access control model

Last updated on
18 October 2024

As discussed in #3162511: The forbidden() result in group_entity_access() breaks regular node grants, the Group module takes over access-control handling for content when that content is grouped. When content is grouped, the Group module will return an AccessResult::forbidden() result if you lack the required group permissions to ensure the promise of strict group-only access control is kept.

An example and Group's default access control

Let's use the example of an academic site that uses Group to provide spaces for instructors to share materials with their students. In this model, there is a separate group for each class with Instructor and Student roles that can be assigned to each member. Instructors can add content to their class-group that will be visible to students.

Content that is not grouped, such as "Page" nodes with help information added outside of a group by a site administrator will have its visibility determined by the Node module's visibility grants, with no interaction from Group.

In contrast to ungrouped content, grouped content (such a "Page" node an instructor adds to their class-group describing an assignment) will be private to just the members of that group. Because content of the same type might be grouped or ungrouped, the Group module cannot trust the Node module's visibility grants and will return an AccessResult::forbidden() result for grouped content when the current user is not a member. Group takes over the final say on visibility when a piece of content is grouped. This default behavior is "safe" in that grouping content will always make it private to the members of a group unless there is a specific group permission to allow group-outsiders to view content and will not result in grouped content being accidentally exposed based on grants from the Node module or other modules.

Expanding visibility

While Group's default behavior is "safe", some sites have use-cases that require tweaking Group's access control model to allow some content in a group to be visible to non-members.

Continuing with the class example, we'd like our site to provide a "visibility" field on the node-edit forms that instructors can change from a default of "the class" to a broader population such as "everyone at the school" or "public to the world".

A real world use case is for instructors being able to choose the visibility of their syllabus and class resources: Some instructors wish to publish some or all of their materials to the world under an open license, while others may wish to keep them restricted due to copyright concerns, proprietary licensing of certain materials, or sensitivity of the topics discussed. the school may wish to encourage at least syllabi to be visible to all students to provide context prior to registration while ultimately placing final control of their materials in the hands of the instructor.

Because Group returns a hard AccessResult::forbidden() result for grouped content, it is not possible for a custom module to simply implement hook_node_access() or hook_entity_access() and return a AccessResult::allowed() result as the AccessResult::forbidden() will win.

Visibility when viewing content

To grant extra visibility based on the value of a field on a node, we must place our extra access checks in an extension of the GroupContentAccessControlHandler. We wire up our extension by implementing hook_group_content_info_alter(). This will cover view access when viewing a node directly.

Note: The following example checks each target bundle to ensure that it has the correct "visibility" field added and uses the default access control handler if not. This may be excessive for some use cases or insufficient for others.

my_module/my_module.module

/**
 * Implements hook_group_content_info_alter().
 */
function my_module_group_content_info_alter(array &$groupContentInfo) {
  // Inject our custom Access Control Handler (ACH) to allow our custom
  // "visibility" field on nodes to open-up access to syllabi and
  // resources when the instructor chooses "Everyone at the School" or
  // "Public / Anyone in the world" as the visibility.
  //
  // Without this, Group 1.3's GroupContentAccessControlHandler now returns an
  // AccessResult::forbidden() result if the content is in a group (as all
  // syllabi and resources are).
  //
  // To ensure that we aren't accidentally exposing content that doesn't have a
  // visibility field, we'll look for the presence of that field on the target
  // entity-type.
  $fieldManager = \Drupal::service('entity_field.manager');
  foreach ($groupContentInfo as &$info) {
    $bundleFields = $fieldManager->getFieldDefinitions($info['entity_type_id'], $info['entity_bundle']);
    // Use our custom ACH to expose group-content more publicly if the target
    // bundle has our custom visibility field.
    if ($info['entity_type_id'] == 'node' && isset($bundleFields['field_visibility'])) {
      $info['handlers']['access'] = 'Drupal\my_module\Plugin\VisibilityFieldGroupContentAccessControlHandler';
    }
  }
}

my_module/src/Plugin/VisibilityFieldGroupContentAccessControlHandler.php

<?php

namespace Drupal\my_module\Plugin;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\group\Plugin\GroupContentAccessControlHandler;

/**
 * Provides access control for GroupContent entities and grouped entities.
 */
class VisibilityFieldGroupContentAccessControlHandler extends GroupContentAccessControlHandler {

  /**
   * {@inheritdoc}
   */
  public function entityAccess(EntityInterface $entity, $operation, AccountInterface $account, $return_as_object = FALSE) {
    $result = parent::entityAccess($entity, $operation, $account, TRUE);

    // For entities that have our site-specific visibility field, open-up
    // view access to syllabi and resources when the instructor chooses
    // "Everyone at the School" or "Public / Anyone in the world" as the value.
    if ($operation == 'view' && $result->isForbidden() && $entity->hasField('field_visibility')) {
      // If the visibility is set to public, allow all to view.
      if ($entity->field_visibility->value == 'public') {
        $result = AccessResult::allowed();
      }
      // Allow authenticated users view-access when 'institution' is chosen.
      // Note: This may be worth refactoring into a role check.
      elseif ($entity->field_visibility->value == 'institution' && !$account->isAnonymous()) {
        $result = AccessResult::allowed();
      }
      // Otherwise, honor the result from the Group module.
    }

    return $return_as_object ? $result : $result->isAllowed();
  }

}

Visibility in views results

While the example above will provide access any place  hook_entity_access() is invoked, it won't cover views listings as the queries have not been altered.

Note: This example is a work in progress taken from a operational site. It may not be the best way to achieve these results. This documentation should be updated if there are better ways to get custom visibility of content respected by views.

Warning: This solution can affect pagination. This hook returns with actual page's results so if you are remove items then it can mess up the excepted paginator behavior.

Here is an example implementation of that directly calls group_entity_access() for each result row in a view. Note that in order to publicly expose content that has our "visibility" field set to "public", this view needs to:

  • Page settings → Access must be set to "Unrestricted" otherwise anonymous visitor won't see any results. (change if your use-case is different)
  • Advanced → Other → Query Settings → Settings → Disable SQL rewriting must be checked. If not, Group will alter queries to add its access checks which will prevent content from ending up in the result rows. This implementation is relying on the result rows to include all matching content and then filtering the results based on our custom access control handler described in the previous section.
/**
 * Implements hook_views_post_execute().
 */
function my_module_views_post_execute(ViewExecutable $view) {
  if ($view->id() == 'my_class_content') {
    // Ensure that node-access controls are applied to our class-content view results.
    $account = \Drupal::currentUser();
    $shown = 0;
    $removed = 0;
    foreach ($view->result as $elementKey => $result) {
      $node = $result->_entity;
      $accessResult = group_entity_access($node, 'view', $account);
      // Remove node if no rights.
      if ($accessResult->isForbidden()) {
        unset($view->result[$elementKey]);
        $removed++;
      }
      else {
        $shown++;
      }
    }
    // Set messages when access is denied to some resources.
    if ($removed > 0) {
      if (\Drupal::currentUser()->isAnonymous()) {
        if ($shown) {
          $view->footer['area_text_custom']->options['content'] = '<div><em>' . ('Some class content is not publicly visible, log in for more.') . '</em></div>';
        }
        $view->empty['area']->options['content']['value'] = t('No class content is publicly visible. Log in for more.');
      }
      else {
        if ($shown) {
          $view->footer['area_text_custom']->options['content'] = '<div><em>' . ('Some class content is visible only to members of this class.') . '</em></div>';
        }
        $view->empty['area']->options['content']['value'] = t('Some class content is visible only to members of this class.');
      }
    }
  }
}

Help improve this page

Page status: No known problems

You can: