Query-level filtering for custom entities in Drupal 8 and 9

Entity API is a great module that extends the entity API of Drupal core. One feature that I particularly like is the entity query access API. You can use it to alter entity queries and views, to only list the entities that the user has access to.

You can find the change record for this feature here.

One use case of the entity query access API is for custom content entities. Let's say that you created a new custom entity called Book by using the Drupal console. By default you will get the following permissions:

  • add book entities
  • administer book entities
  • delete book entities
  • edit book entities
  • view published book entities
  • view unpublished book entities
  • view all book revisions
  • revert all book revisions
  • delete all book revisions

As you can see there is no, for example, Update own book entities or Delete own book entities permissions. In Drupal core, thanks to the Node Access API these permissions exist for Nodes, but for custom entities, you are on your own.

The easiest way to implement per-user permissions is to use the entity query access API. To do that, all you have to do is to alter the entity annotation. Entity handlers generated by the Drupal console look like this:

/**
 * Defines the Book entity.
 *
 * @ingroup book
 *
 * @ContentEntityType(
 *   id = "book_entity",
 *   label = @Translation("Book"),
 *   handlers = {
 *     "storage" = "Drupal\book\BookEntityStorage",
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\book\BookEntityListBuilder",
 *     "views_data" = "Drupal\book\Entity\BookEntityViewsData",
 *     "translation" = "Drupal\book\BookEntityTranslationHandler",
 *
 *     "form" = {
 *       "default" = "Drupal\book\Form\BookEntityForm",
 *       "add" = "Drupal\book\Form\BookEntityForm",
 *       "edit" = "Drupal\book\Form\BookEntityForm",
 *       "delete" = "Drupal\book\Form\BookEntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\book\BookEntityHtmlRouteProvider",
 *     },
 *     "access" = "Drupal\book\BookEntityAccessControlHandler",
 *   },

To use the entity query access API you must update the access handler and add two new handlers (query_access and permission_provider) like this:

/**
 * Defines the Book entity.
 *
 * @ingroup book
 *
 * @ContentEntityType(
 *   id = "book_entity",
 *   label = @Translation("Book"),
 *   handlers = {
 *     "storage" = "Drupal\book\BookEntityStorage",
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\book\BookEntityListBuilder",
 *     "views_data" = "Drupal\book\Entity\BookEntityViewsData",
 *     "translation" = "Drupal\book\BookEntityTranslationHandler",
 *
 *     "form" = {
 *       "default" = "Drupal\book\Form\BookEntityForm",
 *       "add" = "Drupal\book\Form\BookEntityForm",
 *       "edit" = "Drupal\book\Form\BookEntityForm",
 *       "delete" = "Drupal\book\Form\BookEntityDeleteForm",
 *     },
 *     "route_provider" = {
 *       "html" = "Drupal\book\BookEntityHtmlRouteProvider",
 *     },
 *     "access" = "\Drupal\entity\EntityAccessControlHandler",
 *     "query_access" = "\Drupal\entity\QueryAccess\QueryAccessHandler",
 *     "permission_provider" = "\Drupal\entity\EntityPermissionProvider",
 *   },

If you now check the Book entity permissions list, you will see that you have additional permissions such as:

  • delete own book entities
  • update own book entities
  • view own unpublished book entities
  • ... and more

If you want to alter an entity provided by a contrib module, you can use hook_entity_type_alter() like this:

/**
 * Implements hook_entity_type_alter().
 */
function MY_MODULE_entity_type_alter(array &$entity_types) {
  $entity_types['some_entity_type']->setHandlerClass('access', '\Drupal\entity\EntityAccessControlHandler');
  $entity_types['some_entity_type']->setHandlerClass('query_access', '\Drupal\entity\QueryAccess\QueryAccessHandler');
  $entity_types['some_entity_type']->setHandlerClass('permission_provider', '\Drupal\entity\EntityPermissionProvider');
}

If that's all you need, you don't have to do anything else. But, if you need to alter the generated access conditions in order to add additional filtering based on some other factors like user's email, entity IDs, or anything else you can subscribe to the "entity.query_access.$entity_type_id" event.

In our case entity type id is the book, so our subscriber looks like this:

MY_MODULE.services.yml

services:
  MY_MODULE.query_access_subscriber:
    class: Drupal\MY_MODULE\EventSubscriber\QueryAccessSubscriber
    tags:
      - { name: event_subscriber }

QueryAccessSubscriber.php

<?php

namespace Drupal\MY_MODULE\EventSubscriber;

use Drupal\entity\QueryAccess\ConditionGroup;
use Drupal\entity\QueryAccess\QueryAccessEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class QueryAccessSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents() {
    return [
      'entity.query_access.book' => 'onQueryAccess',
    ];
  }

  /**
   * Modifies the access conditions based on the current user.
   */
  public function onQueryAccess(QueryAccessEvent $event) {
    $conditions = $event->getConditions();
    $email = $event->getAccount()->getEmail();

    if ($email == 'bad.user@example.com') {
      // This user should not have access to any entities.
      $conditions->alwaysFalse();
    }
    elseif ($email == 'limited.user@example.com') {
      // This user should have access to entities with the IDs 1, 2, and 3.
      // The query access handler might have already set ->alwaysFalse()
      // due to the user not having any other access, so we make sure
      // to undo it with $conditions->alwaysFalse(TRUE).
      $conditions->alwaysFalse(FALSE);
      $conditions->addCondition('id', ['1', '2', '3']);
    }
  }

}

You can find more examples in the Entity API test module and if you want to see a real-world example, check out how Drupal Commerce is using this feature here: issue #2499645.

If you want to disable access checking in a specific view, you can do that by checking Disable SQL rewriting checkbox in the Query options.

Image

Entity API is respecting this setting thanks to this issue: Views query alter should respect the "Disable SQL Rewriting" setting 

About the Author

Goran Nikolovski is an experienced web and AI developer skilled in Drupal, React, and React Native. He founded this website and enjoys sharing his knowledge.