Listing custom entities with proper view access

Created on 6 September 2018, over 6 years ago
Updated 21 August 2023, over 1 year ago

Probably this is not the right place for this, but I didn't find better place for this tutorial. I wrote this nearly 1 year ago, and now I post it here for the posterity, maybe somebody will use it until the https://www.drupal.org/project/drupal/issues/777578 ✨ Add an entity query access API and deprecate hook_query_ENTITY_TYPE_access_alter() Needs work fixed/implemented. Please note this was written originaly for a blog post, and it's pritty detailed, but use the current version of the code, here the code snipets are just for identify which code part you will need. Oh, and I don't say this is the best way to do it, this was just an easy to use and implement solution.

We have a project, called QAShot, where we use custom entities and we needed to filtered out those custom entities, which the user does not have view permission to it. Because in that time there wasn't any source for this, I will show how to do it 'with' the core's node access code.

What was the possibilities to list custom entities with right view permission?

At least if I want to do it, probably there's only two possible choice which can be considered.

  1. The first I create a custom listings, which is possible to set under list_builder and somehow I add sorting, access control and everything else which I will need.
  2. The second possibility is to add better access control into views listing.

After a few search with google, some search inside the core code and I found the access is possible to solve more than the custom listing page, at least because there's a module which uses this type of access restriction (node module), but there's no module which using that type of custom listings. So I think it's clear why I chose this.

So how it will work?

Actually... like at nodes. Because we need to 'reimplement the wheel', of course we won't do it, but we will need to copy a lot of code from the core to our project. Actually I didn't find any useful information about how to integrate inside the node access with custom entity in that time or how to do it with less code and I didn't analysed too much the codes (what a shame...). However I needed a relatively fast and working solution.

Copy and paste - our ugly good old friend :)

OK. OK. But what do I need to do? I want some code!

Well, in that case, let's see what files do you need, but a little side note. You not really need in all cases all these files or functions, just leave out and remove those code's which you don't need. Like if you don't need UidReversion filter, just... don't copy it. Also I suppose you have a custom entity which you want to extend with this 'function'. So here's a list from those files which you need fully:

modules/node/src/Cache/NodeAccessGrantsCacheContext.php
modules/node/src/Form/RebuildPermissionsForm.php
modules/node/src/Plugin/views/filter/Access.php
modules/node/src/Plugin/views/filter/Status.php (optional)
modules/node/src/Plugin/views/filter/UidRevision.php (optional)
modules/node/src/NodeGrantDatabaseStorage.php
modules/node/src/NodeGrantDatabaseStorageInterface.php
modules/node/src/NodeAccessControlHandlerInterface.php
modules/node/src/NodeAccessControlHandler.php
modules/node/src/NodeTypeAccessControlHandler.php (optional)
modules/node/node.views_execution.inc
modules/node/node.api.php

I want to note here, inside the modules/node/src/NodeAccessControlHandler.php file, is the access permissions which probably you previously implemented, so you need to check your code, to be sure you're using the same format as the code which is inside the modules/node/src/NodeAccessControlHandler.php file. (In sort use cache.)

And here is those files which you will need but not everything from it:

modules/node/config/schema/node.views.schema.yml
modules/node/src/Entity/Node.php
modules/node/src/NodeViewsData.php
modules/node/src/NodeListBuilder.php (optional)
modules/node/node.install
modules/node/node.module
modules/node/node.routing.yml
modules/node/node.services.yml

Now let see what exactly do you need from these files.

modules/node/config/schema/node.views.schema.yml:

views.argument.node_uid_revision:
  type: views_argument
  label: 'Node user ID'
  mapping:
    break_phrase:
      type: boolean
      label: 'Allow multiple values'
    not:
      type: boolean
      label: 'Exclude'

views.filter.node_access:
  type: views_filter
  label: 'Node access'

views.filter.node_status:
  type: views_filter
  label: 'Node status'

views.filter.node_uid_revision:
  type: views_filter
  label: 'Node revisions of an user'
  mapping:
    operator:
      type: string
      label: 'Operator'
    value:
      type: sequence
      label: 'Values'
      sequence:
        type: string
        label: 'Value'
    expose:
      type: mapping
      label: 'Expose'
      mapping:
        reduce:
          type: boolean
          label: 'Reduce'

views.filter_value.node_access:
  type: string
  label: 'Access'

views.filter_value.node_status:
  type: boolean
  label: 'Status'

modules/node/src/Entity/Node.php:

  /**
   * {@inheritdoc}
   */
  public function preSave(EntityStorageInterface $storage) {
    parent::preSave($storage);

    foreach (array_keys($this->getTranslationLanguages()) as $langcode) {
      $translation = $this->getTranslation($langcode);

      // If no owner has been set explicitly, make the anonymous user the owner.
      if (!$translation->getOwner()) {
        $translation->setOwnerId(0);
      }
    }

    // If no revision author has been set explicitly, make the node owner the
    // revision author.
    if (!$this->getRevisionUser()) {
      $this->setRevisionUserId($this->getOwnerId());
    }
  }

  /**
   * {@inheritdoc}
   */
  public function preSaveRevision(EntityStorageInterface $storage, \stdClass $record) {
    parent::preSaveRevision($storage, $record);

    if (!$this->isNewRevision() && isset($this->original) && (!isset($record->revision_log) || $record->revision_log === '')) {
      // If we are updating an existing node without adding a new revision, we
      // need to make sure $entity->revision_log is reset whenever it is empty.
      // Therefore, this code allows us to avoid clobbering an existing log
      // entry with an empty one.
      $record->revision_log = $this->original->revision_log->value;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function postSave(EntityStorageInterface $storage, $update = TRUE) {
    parent::postSave($storage, $update);

    // Update the node access table for this node, but only if it is the
    // default revision. There's no need to delete existing records if the node
    // is new.
    if ($this->isDefaultRevision()) {
      /** @var \Drupal\node\NodeAccessControlHandlerInterface $access_control_handler */
      $access_control_handler = \Drupal::entityManager()->getAccessControlHandler('node');
      $grants = $access_control_handler->acquireGrants($this);
      \Drupal::service('node.grant_storage')->write($this, $grants, NULL, $update);
    }

    // Reindex the node when it is updated. The node is automatically indexed
    // when it is added, simply by being added to the node table.
    if ($update) {
      node_reindex_node_search($this->id());
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function preDelete(EntityStorageInterface $storage, array $entities) {
    parent::preDelete($storage, $entities);

    // Ensure that all nodes deleted are removed from the search index.
    if (\Drupal::moduleHandler()->moduleExists('search')) {
      foreach ($entities as $entity) {
        search_index_clear('node_search', $entity->nid->value);
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function postDelete(EntityStorageInterface $storage, array $nodes) {
    parent::postDelete($storage, $nodes);
    \Drupal::service('node.grant_storage')->deleteNodeRecords(array_keys($nodes));
  }

  /**
   * {@inheritdoc}
   */
  public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) {
    // This override exists to set the operation to the default value "view".
    return parent::access($operation, $account, $return_as_object);
  }

Probably you will need these functions or some of them. If you have these, merge with the current ones.

modules/node/src/NodeViewsData.php:

    $data['node_field_data']['table']['base']['access query tag'] = 'node_access';

    // Define the base group of this table. Fields that don't have a group defined
    // will go into this field by default.
    $data['node_access']['table']['group']  = $this->t('Content access');

    // For other base tables, explain how we join.
    /*$data['node_access']['table']['join'] = [
      'node_field_data' => [
        'left_field' => 'nid',
        'field' => 'nid',
      ],
    ];*/
    $data['node_access']['nid'] = [
      'title' => $this->t('Access'),
      'help' => $this->t('Filter by access.'),
      'filter' => [
        'id' => 'node_access',
        'help' => $this->t('Filter for content by view access. <strong>Not necessary if you are using node as your base table.</strong>'),
      ],
    ];

modules/node/src/NodeListBuilder.php:

Because you have a (probably) 'fully' implemented custom entity you also have a list builder. So you need to merge only those functions which you need, actually this is only a fallback normally.

modules/node/node.install:

/**
 * Implements hook_requirements().
 */
function node_requirements($phase) {
  $requirements = [];
  if ($phase === 'runtime') {
    // Only show rebuild button if there are either 0, or 2 or more, rows
    // in the {node_access} table, or if there are modules that
    // implement hook_node_grants().
    $grant_count = \Drupal::entityManager()->getAccessControlHandler('node')->countGrants();
    if ($grant_count != 1 || count(\Drupal::moduleHandler()->getImplementations('node_grants')) > 0) {
      $value = \Drupal::translation()->formatPlural($grant_count, 'One permission in use', '@count permissions in use', ['@count' => $grant_count]);
    }
    else {
      $value = t('Disabled');
    }

    $requirements['node_access'] = [
      'title' => t('Node Access Permissions'),
      'value' => $value,
      'description' => t('If the site is experiencing problems with permissions to content, you may have to rebuild the permissions cache. Rebuilding will remove all privileges to content and replace them with permissions based on the current modules and settings. Rebuilding may take some time if there is a lot of content or complex permission settings. After rebuilding has completed, content will automatically use the new permissions. <a href=":rebuild">Rebuild permissions</a>', [
        ':rebuild' => \Drupal::url('node.configure_rebuild_confirm'),
      ]),
    ];
  }
  return $requirements;
}

/**
 * Implements hook_schema().
 */
function node_schema() {
  $schema['node_access'] = [
    'description' => 'Identifies which realm/grant pairs a user must possess in order to view, update, or delete specific nodes.',
    'fields' => [
      'nid' => [
        'description' => 'The {node}.nid this record affects.',
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 0,
      ],
      'langcode' => [
        'description' => 'The {language}.langcode of this node.',
        'type' => 'varchar_ascii',
        'length' => 12,
        'not null' => TRUE,
        'default' => '',
      ],
      'fallback' => [
        'description' => 'Boolean indicating whether this record should be used as a fallback if a language condition is not provided.',
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 1,
        'size' => 'tiny',
      ],
      'gid' => [
        'description' => "The grant ID a user must possess in the specified realm to gain this row's privileges on the node.",
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 0,
      ],
      'realm' => [
        'description' => 'The realm in which the user must possess the grant ID. Each node access node can define one or more realms.',
        'type' => 'varchar_ascii',
        'length' => 255,
        'not null' => TRUE,
        'default' => '',
      ],
      'grant_view' => [
        'description' => 'Boolean indicating whether a user with the realm/grant pair can view this node.',
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 0,
        'size' => 'tiny',
      ],
      'grant_update' => [
        'description' => 'Boolean indicating whether a user with the realm/grant pair can edit this node.',
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 0,
        'size' => 'tiny',
      ],
      'grant_delete' => [
        'description' => 'Boolean indicating whether a user with the realm/grant pair can delete this node.',
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'default' => 0,
        'size' => 'tiny',
      ],
    ],
    'primary key' => ['nid', 'gid', 'realm', 'langcode'],
    'foreign keys' => [
      'affected_node' => [
        'table' => 'node',
        'columns' => ['nid' => 'nid'],
      ],
    ],
  ];

  return $schema;
}

/**
 * Implements hook_install().
 */
function node_install() {
  // Enable default permissions for system roles.
  // IMPORTANT: Modules SHOULD NOT automatically grant any user role access
  // permissions in hook_install().
  // However, the 'access content' permission is a very special case, since
  // there is hardly a point in installing the Node module without granting
  // these permissions. Doing so also allows tests to continue to operate as
  // expected without first having to manually grant these default permissions.
  if (\Drupal::moduleHandler()->moduleExists('user')) {
    user_role_grant_permissions(RoleInterface::ANONYMOUS_ID, ['access content']);
    user_role_grant_permissions(RoleInterface::AUTHENTICATED_ID, ['access content']);
  }

  // Populate the node access table.
  db_insert('node_access')
    ->fields([
      'nid' => 0,
      'gid' => 0,
      'realm' => 'all',
      'grant_view' => 1,
      'grant_update' => 0,
      'grant_delete' => 0,
    ])
    ->execute();
}

/**
 * Implements hook_uninstall().
 */
function node_uninstall() {
  // Delete remaining general module variables.
  \Drupal::state()->delete('node.node_access_needs_rebuild');
}

modules/node/node.module:

/**
 * @defgroup node_access Node access rights
 * @{
 * The node access system determines who can do what to which nodes.
 *
 * In determining access rights for a node, \Drupal\node\NodeAccessControlHandler
 * first checks whether the user has the "bypass node access" permission. Such
 * users have unrestricted access to all nodes. user 1 will always pass this
 * check.
 *
 * Next, all implementations of hook_node_access() will be called. Each
 * implementation may explicitly allow, explicitly forbid, or ignore the access
 * request. If at least one module says to forbid the request, it will be
 * rejected. If no modules deny the request and at least one says to allow it,
 * the request will be permitted.
 *
 * If all modules ignore the access request, then the node_access table is used
 * to determine access. All node access modules are queried using
 * hook_node_grants() to assemble a list of "grant IDs" for the user. This list
 * is compared against the table. If any row contains the node ID in question
 * (or 0, which stands for "all nodes"), one of the grant IDs returned, and a
 * value of TRUE for the operation in question, then access is granted. Note
 * that this table is a list of grants; any matching row is sufficient to grant
 * access to the node.
 *
 * In node listings (lists of nodes generated from a select query, such as the
 * default home page at path 'node', an RSS feed, a recent content block, etc.),
 * the process above is followed except that hook_node_access() is not called on
 * each node for performance reasons and for proper functioning of the pager
 * system. When adding a node listing to your module, be sure to use an entity
 * query, which will add a tag of "node_access". This will allow modules dealing
 * with node access to ensure only nodes to which the user has access are
 * retrieved, through the use of hook_query_TAG_alter(). See the
 * @link entity_api Entity API topic @endlink for more information on entity
 * queries. Tagging a query with "node_access" does not check the
 * published/unpublished status of nodes, so the base query is responsible
 * for ensuring that unpublished nodes are not displayed to inappropriate users.
 *
 * Note: Even a single module returning an AccessResultInterface object from
 * hook_node_access() whose isForbidden() method equals TRUE will block access
 * to the node. Therefore, implementers should take care to not deny access
 * unless they really intend to. Unless a module wishes to actively forbid
 * access it should return an AccessResultInterface object whose isAllowed() nor
 * isForbidden() methods return TRUE, to allow other modules or the node_access
 * table to control access.
 *
 * To see how to write a node access module of your own, see
 * node_access_example.module.
 */

/**
 * Fetches an array of permission IDs granted to the given user ID.
 *
 * The implementation here provides only the universal "all" grant. A node
 * access module should implement hook_node_grants() to provide a grant list for
 * the user.
 *
 * After the default grants have been loaded, we allow modules to alter the
 * grants array by reference. This hook allows for complex business logic to be
 * applied when integrating multiple node access modules.
 *
 * @param string $op
 *   The operation that the user is trying to perform.
 * @param \Drupal\Core\Session\AccountInterface $account
 *   The account object for the user performing the operation.
 *
 * @return array
 *   An associative array in which the keys are realms, and the values are
 *   arrays of grants for those realms.
 */
function node_access_grants($op, AccountInterface $account) {
  // Fetch node access grants from other modules.
  $grants = \Drupal::moduleHandler()->invokeAll('node_grants', [$account, $op]);
  // Allow modules to alter the assigned grants.
  \Drupal::moduleHandler()->alter('node_grants', $grants, $account, $op);

  return array_merge(['all' => [0]], $grants);
}


/**
 * Determines whether the user has a global viewing grant for all nodes.
 *
 * Checks to see whether any module grants global 'view' access to a user
 * account; global 'view' access is encoded in the {node_access} table as a
 * grant with nid=0. If no node access modules are enabled, node.module defines
 * such a global 'view' access grant.
 *
 * This function is called when a node listing query is tagged with
 * 'node_access'; when this function returns TRUE, no node access joins are
 * added to the query.
 *
 * @param $account
 *   (optional) The user object for the user whose access is being checked. If
 *   omitted, the current user is used. Defaults to NULL.
 *
 * @return
 *   TRUE if 'view' access to all nodes is granted, FALSE otherwise.
 *
 * @see hook_node_grants()
 * @see node_query_node_access_alter()
 */
function node_access_view_all_nodes($account = NULL) {

  if (!$account) {
    $account = \Drupal::currentUser();
  }

  // Statically cache results in an array keyed by $account->id().
  $access = &drupal_static(__FUNCTION__);
  if (isset($access[$account->id()])) {
    return $access[$account->id()];
  }

  // If no modules implement the node access system, access is always TRUE.
  if (!\Drupal::moduleHandler()->getImplementations('node_grants')) {
    $access[$account->id()] = TRUE;
  }
  else {
    $access[$account->id()] = \Drupal::entityManager()->getAccessControlHandler('node')->checkAllGrants($account);
  }

  return $access[$account->id()];
}


/**
 * Implements hook_query_TAG_alter().
 *
 * This is the hook_query_alter() for queries tagged with 'node_access'. It adds
 * node access checks for the user account given by the 'account' meta-data (or
 * current user if not provided), for an operation given by the 'op' meta-data
 * (or 'view' if not provided; other possible values are 'update' and 'delete').
 *
 * Queries tagged with 'node_access' that are not against the {node} table
 * must add the base table as metadata. For example:
 * @code
 *   $query
 *     ->addTag('node_access')
 *     ->addMetaData('base_table', 'taxonomy_index');
 * @endcode
 */
function node_query_node_access_alter(AlterableInterface $query) {
  // Read meta-data from query, if provided.
  if (!$account = $query->getMetaData('account')) {
    $account = \Drupal::currentUser();
  }
  if (!$op = $query->getMetaData('op')) {
    $op = 'view';
  }

  // If $account can bypass node access, or there are no node access modules,
  // or the operation is 'view' and the $account has a global view grant
  // (such as a view grant for node ID 0), we don't need to alter the query.
  if ($account->hasPermission('bypass node access')) {
    return;
  }
  if (!count(\Drupal::moduleHandler()->getImplementations('node_grants'))) {
    return;
  }
  if ($op == 'view' && node_access_view_all_nodes($account)) {
    return;
  }

  $tables = $query->getTables();
  $base_table = $query->getMetaData('base_table');
  // If the base table is not given, default to one of the node base tables.
  if (!$base_table) {
    /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
    $table_mapping = \Drupal::entityTypeManager()->getStorage('node')->getTableMapping();
    $node_base_tables = $table_mapping->getTableNames();

    foreach ($tables as $table_info) {
      if (!($table_info instanceof SelectInterface)) {
        $table = $table_info['table'];
        // Ensure that 'node' and 'node_field_data' are always preferred over
        // 'node_revision' and 'node_field_revision'.
        if ($table == 'node' || $table == 'node_field_data') {
          $base_table = $table;
          break;
        }
        // If one of the node base tables are in the query, add it to the list
        // of possible base tables to join against.
        if (in_array($table, $node_base_tables)) {
          $base_table = $table;
        }
      }
    }

    // Bail out if the base table is missing.
    if (!$base_table) {
      throw new Exception(t('Query tagged for node access but there is no node table, specify the base_table using meta data.'));
    }
  }

  // Update the query for the given storage method.
  \Drupal::service('node.grant_storage')->alterQuery($query, $tables, $op, $account, $base_table);

  // Bubble the 'user.node_grants:$op' cache context to the current render
  // context.
  $request = \Drupal::requestStack()->getCurrentRequest();
  $renderer = \Drupal::service('renderer');
  if ($request->isMethodSafe() && $renderer->hasRenderContext()) {
    $build = ['#cache' => ['contexts' => ['user.node_grants:' . $op]]];
    $renderer->render($build);
  }
}

/**
 * Toggles or reads the value of a flag for rebuilding the node access grants.
 *
 * When the flag is set, a message is displayed to users with 'access
 * administration pages' permission, pointing to the 'rebuild' confirm form.
 * This can be used as an alternative to direct node_access_rebuild calls,
 * allowing administrators to decide when they want to perform the actual
 * (possibly time consuming) rebuild.
 *
 * When unsure if the current user is an administrator, node_access_rebuild()
 * should be used instead.
 *
 * @param $rebuild
 *   (optional) The boolean value to be written.
 *
 * @return bool|null
 *   The current value of the flag if no value was provided for $rebuild. If a
 *   value was provided for $rebuild, nothing (NULL) is returned.
 *
 * @see node_access_rebuild()
 */
function node_access_needs_rebuild($rebuild = NULL) {
  if (!isset($rebuild)) {
    return \Drupal::state()->get('node.node_access_needs_rebuild') ?: FALSE;
  }
  elseif ($rebuild) {
    \Drupal::state()->set('node.node_access_needs_rebuild', TRUE);
  }
  else {
    \Drupal::state()->delete('node.node_access_needs_rebuild');
  }
}

/**
 * Rebuilds the node access database.
 *
 * This rebuild is occasionally needed by modules that make system-wide changes
 * to access levels. When the rebuild is required by an admin-triggered action
 * (e.g module settings form), calling node_access_needs_rebuild(TRUE) instead
 * of node_access_rebuild() lets the user perform his changes and actually
 * rebuild only once he is done.
 *
 * Note : As of Drupal 6, node access modules are not required to (and actually
 * should not) call node_access_rebuild() in hook_install/uninstall anymore.
 *
 * @param $batch_mode
 *   (optional) Set to TRUE to process in 'batch' mode, spawning processing over
 *   several HTTP requests (thus avoiding the risk of PHP timeout if the site
 *   has a large number of nodes). hook_update_N() and any form submit handler
 *   are safe contexts to use the 'batch mode'. Less decidable cases (such as
 *   calls from hook_user(), hook_taxonomy(), etc.) might consider using the
 *   non-batch mode. Defaults to FALSE.
 *
 * @see node_access_needs_rebuild()
 */
function node_access_rebuild($batch_mode = FALSE) {
  $node_storage = \Drupal::entityManager()->getStorage('node');
  /** @var \Drupal\node\NodeAccessControlHandlerInterface $access_control_handler */
  $access_control_handler = \Drupal::entityManager()->getAccessControlHandler('node');
  $access_control_handler->deleteGrants();
  // Only recalculate if the site is using a node_access module.
  if (count(\Drupal::moduleHandler()->getImplementations('node_grants'))) {
    if ($batch_mode) {
      $batch = [
        'title' => t('Rebuilding content access permissions'),
        'operations' => [
          ['_node_access_rebuild_batch_operation', []],
        ],
        'finished' => '_node_access_rebuild_batch_finished'
      ];
      batch_set($batch);
    }
    else {
      // Try to allocate enough time to rebuild node grants
      drupal_set_time_limit(240);

      // Rebuild newest nodes first so that recent content becomes available
      // quickly.
      $entity_query = \Drupal::entityQuery('node');
      $entity_query->sort('nid', 'DESC');
      // Disable access checking since all nodes must be processed even if the
      // user does not have access. And unless the current user has the bypass
      // node access permission, no nodes are accessible since the grants have
      // just been deleted.
      $entity_query->accessCheck(FALSE);
      $nids = $entity_query->execute();
      foreach ($nids as $nid) {
        $node_storage->resetCache([$nid]);
        $node = Node::load($nid);
        // To preserve database integrity, only write grants if the node
        // loads successfully.
        if (!empty($node)) {
          $grants = $access_control_handler->acquireGrants($node);
          \Drupal::service('node.grant_storage')->write($node, $grants);
        }
      }
    }
  }
  else {
    // Not using any node_access modules. Add the default grant.
    $access_control_handler->writeDefaultGrant();
  }

  if (!isset($batch)) {
    drupal_set_message(t('Content permissions have been rebuilt.'));
    node_access_needs_rebuild(FALSE);
  }
}

/**
 * Implements callback_batch_operation().
 *
 * Performs batch operation for node_access_rebuild().
 *
 * This is a multistep operation: we go through all nodes by packs of 20. The
 * batch processing engine interrupts processing and sends progress feedback
 * after 1 second execution time.
 *
 * @param array $context
 *   An array of contextual key/value information for rebuild batch process.
 */
function _node_access_rebuild_batch_operation(&$context) {
  $node_storage = \Drupal::entityManager()->getStorage('node');
  if (empty($context['sandbox'])) {
    // Initiate multistep processing.
    $context['sandbox']['progress'] = 0;
    $context['sandbox']['current_node'] = 0;
    $context['sandbox']['max'] = \Drupal::entityQuery('node')->accessCheck(FALSE)->count()->execute();
  }

  // Process the next 20 nodes.
  $limit = 20;
  $nids = \Drupal::entityQuery('node')
    ->condition('nid', $context['sandbox']['current_node'], '>')
    ->sort('nid', 'ASC')
    // Disable access checking since all nodes must be processed even if the
    // user does not have access. And unless the current user has the bypass
    // node access permission, no nodes are accessible since the grants have
    // just been deleted.
    ->accessCheck(FALSE)
    ->range(0, $limit)
    ->execute();
  $node_storage->resetCache($nids);
  $nodes = Node::loadMultiple($nids);
  foreach ($nodes as $nid => $node) {
    // To preserve database integrity, only write grants if the node
    // loads successfully.
    if (!empty($node)) {
      /** @var \Drupal\node\NodeAccessControlHandlerInterface $access_control_handler */
      $access_control_handler = \Drupal::entityManager()->getAccessControlHandler('node');
      $grants = $access_control_handler->acquireGrants($node);
      \Drupal::service('node.grant_storage')->write($node, $grants);
    }
    $context['sandbox']['progress']++;
    $context['sandbox']['current_node'] = $nid;
  }

  // Multistep processing : report progress.
  if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
    $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
  }
}

/**
 * Implements callback_batch_finished().
 *
 * Performs post-processing for node_access_rebuild().
 *
 * @param bool $success
 *   A boolean indicating whether the re-build process has completed.
 * @param array $results
 *   An array of results information.
 * @param array $operations
 *   An array of function calls (not used in this function).
 */
function _node_access_rebuild_batch_finished($success, $results, $operations) {
  if ($success) {
    drupal_set_message(t('The content access permissions have been rebuilt.'));
    node_access_needs_rebuild(FALSE);
  }
  else {
    drupal_set_message(t('The content access permissions have not been properly rebuilt.'), 'error');
  }
}

/**
 * @} End of "defgroup node_access".
 */

/**
 * Implements hook_modules_installed().
 */
function node_modules_installed($modules) {
  // Check if any of the newly enabled modules require the node_access table to
  // be rebuilt.
  if (!node_access_needs_rebuild() && array_intersect($modules, \Drupal::moduleHandler()->getImplementations('node_grants'))) {
    node_access_needs_rebuild(TRUE);
  }
}

/**
 * Implements hook_modules_uninstalled().
 */
function node_modules_uninstalled($modules) {
  // Check whether any of the disabled modules implemented hook_node_grants(),
  // in which case the node access table needs to be rebuilt.
  foreach ($modules as $module) {
    // At this point, the module is already disabled, but its code is still
    // loaded in memory. Module functions must no longer be called. We only
    // check whether a hook implementation function exists and do not invoke it.
    // Node access also needs to be rebuilt if language module is disabled to
    // remove any language-specific grants.
    if (!node_access_needs_rebuild() && (\Drupal::moduleHandler()->implementsHook($module, 'node_grants') || $module == 'language')) {
      node_access_needs_rebuild(TRUE);
    }
  }

  // If there remains no more node_access module, rebuilding will be
  // straightforward, we can do it right now.
  if (node_access_needs_rebuild() && count(\Drupal::moduleHandler()->getImplementations('node_grants')) == 0) {
    node_access_rebuild();
  }
}

modules/node/node.routing.yml:

node.configure_rebuild_confirm:
  path: '/admin/reports/status/rebuild'
  defaults:
    _form: 'Drupal\node\Form\RebuildPermissionsForm'
  requirements:
    _permission: 'access administration pages'

modules/node/node.services.yml:

  node.grant_storage:
    class: Drupal\node\NodeGrantDatabaseStorage
    arguments: ['@database', '@module_handler', '@language_manager']
    tags:
      - { name: backend_overridable }
  cache_context.user.node_grants:
    class: Drupal\node\Cache\NodeAccessGrantsCacheContext
    arguments: ['@current_user']
    tags:
      - { name: cache.context }

The refactoring

After we copied everything out the next challenge will be to refactor the things. Actually everywhere in the copied code you need to replace the 'node' to your entity name or your module's name (everywhere, in strings, function names, etc). But this won't be enough, because there's some special cases, which can broke, if you left out, I will highlight those here. Also you will need to import some namespaces and change all file's namespace. Also, don't forget to rename annotations which are in comments. So lets see:

In your .module file you need to change to your module name the 'node' if the function name starts with node (because those are hooks). The others can be your entity's machine name.

These are functions, which are implemented later in the .module file.

function node_access_rebuild($batch_mode = FALSE) {
  // [...]
        'operations' => [
          ['_node_access_rebuild_batch_operation', []],
        ],
        'finished' => '_node_access_rebuild_batch_finished'
  // [...]
}

Also don't forget to check the access, because maybe you want to use different or you don't want to connect your entity to 'bypass node access' permission:

function node_query_node_access_alter(AlterableInterface $query) {
  // Read meta-data from query, if provided.
  if (!$account = $query->getMetaData('account')) {
    $account = \Drupal::currentUser();
  }
  if (!$op = $query->getMetaData('op')) {
    $op = 'view';
  }

  // If $account can bypass node access, or there are no node access modules,
  // or the operation is 'view' and the $account has a global view grant
  // (such as a view grant for node ID 0), we don't need to alter the query.
  if ($account->hasPermission('bypass node access')) {
    return;
  }
  if (!count(\Drupal::moduleHandler()->getImplementations('node_grants'))) {
    return;
  }
  if ($op == 'view' && node_access_view_all_nodes($account)) {
    return;
  }
  // [...]
}

Also you need to change some other code:

function node_access_rebuild($batch_mode = FALSE) {
  // [...]
      foreach ($nids as $nid) {
        $node_storage->resetCache([$nid]);
        $node = Node::load($nid); // <--------------------- change this, to load your entity
        // To preserve database integrity, only write grants if the node
        // loads successfully.
        if (!empty($node)) {
          $grants = $access_control_handler->acquireGrants($node);
          \Drupal::service('node.grant_storage')->write($node, $grants);
        }
      }
  // [...]
}

function _node_access_rebuild_batch_operation(&$context) {
  // [...]
  $node_storage->resetCache($nids);
  $nodes = Node::loadMultiple($nids); // <---------------- and this too
  foreach ($nodes as $nid => $node) {
    // [...]
  }
  // [...]
}

Inside the .install file you need to take care about the default permission which is inside the hook_install():

function node_install() {
  if (\Drupal::moduleHandler()->moduleExists('user')) {
    user_role_grant_permissions(RoleInterface::ANONYMOUS_ID, ['access content']);
    user_role_grant_permissions(RoleInterface::AUTHENTICATED_ID, ['access content']);
  }

  // Populate the node access table.
  db_insert('node_access')
    ->fields([
      'nid' => 0,
      'gid' => 0,
      'realm' => 'all',
      'grant_view' => 1,
      'grant_update' => 0,
      'grant_delete' => 0,
    ])
    ->execute();
}

Probably the first if doesn't need for you (so you can delete it), but the last one specify the default with this, anybody can view anything. Also, make sure, you keep this sync with modules/node/src/NodeAccessControlHandler.php::acquireGrants function's value and with modules/node/src/NodeGrantDatabaseStorage.php::writeDefault value.

If you watch the node_schema function, you will see, this is the node_access table. Be careful how you change these column names and table name, because you will need to rewrite at a few places this if you change.

In modules/node/src/NodeViewsData.php this line:

    $data['node_field_data']['table']['base']['access query tag'] = 'node_access';

will determinate which function will be called automatically when your views will rendered with your custom entity type. You need to change 'node_field_data' to your entity's database table's name. Node store's their data in the 'node_field_data' table, so you need to change this to that table, where you store your custom entities data. Another note: 'node_access' here is a query tag which will be added every single query which will be called throw the views. It will be used in HOOK_query_TAG_alter() which is implemented in .module as node_query_node_access_alter.

The access filter (modules/node/src/Plugin/views/filter/Access.php) will be used only in that case if you add the access filter to the views, like if you join to a node view your custom entity type, and you want to filter out your entities too by access.

Make sure your rewrite here the

if (!$account->hasPermission('bypass node access'))

line or it won't always work as expected.

In modules/node/src/Plugin/views/filter/Status.php there's some query tokens, be sure you rename at modules/node/node.views_execution.inc::node_views_query_substitutions as well. And if we are here, I want to note, make sure you rewrite all message/warning/error message, or at least you wrote in something, because it can be confusing if you using node and custom access, the same will throw the same message, but the user won't know which has problem. So at least write your entity type after the message.

And least probably you won't need this: modules/node/src/NodeTypeAccessControlHandler.php because you don't have multiple type of custom entities, like node and you will need to merge the modules/node/src/NodeAccessControlHandler.php with your own access control handler.

If everything went well, you need a cache rebuild, than you need to reinstall your module and you still has a working drupal.

Final steps to the haven

Well, after a lot of copying and lots of 'find and replace' we are in the final corner. Our custom entity has access control. Now we need to do only the last step. Specify the proper access to each entity. In QAShot it looks like this:

/**
 * Implements hook_qa_shot_test_access_records().
 */
function qa_shot_qa_shot_test_access_records(QAShotTestInterface $qa_shot_test) {
  $grants = [];

  $ids = \Drupal::entityQuery('user')
    ->condition('status', 1)
    ->execute();
  $users = User::loadMultiple($ids);

  foreach ($users as $user) {
    /* @var $user \Drupal\user\Entity\User */
    $grants[] = [
      'id' => $qa_shot_test->id(),
      'realm' => 'qa_shot_access',
      'gid' => $user->id(),
      'grant_view' => $qa_shot_test->access('view', $user),
      'grant_update' => $qa_shot_test->access('update', $user),
      'grant_delete' => $qa_shot_test->access('delete', $user),
    ];
  }

  return $grants;
}

/**
 * Implements hook_qa_shot_test_grants().
 *
 * Make this as simple as possible, because this will run always when there's
 * need an access check.
 */
function qa_shot_qa_shot_test_grants(AccountInterface $account, $op) {
  $grants = [];

  if ($account->id() != 0) {
    // Otherwise return uid, might match entry in table.
    $grants['qa_shot_access'][] = $account->id();
  }

  return $grants;
}

Where 'qa_shot' is the module name and 'qa_shot_test' is the entity name. If you don't remember how you named the hooks, you can find in the module_name.api.php file. When the implementation is done, a last cache rebuild and (normally) done, you successfully implemented the node_access into your custom entity as a specific entity access.

Ohhh nooo...

I can do anything the entity filtering doesn't work nor the base view nor the custom filter!
Don't you forget to implement the hook_YOURCUSTOMNAME_access_records and hook_YOURCUSTOMNAME_grants are they executed? Don't forget, you need a cache rebuild after you wrote these!

If I create a views based on my entity, it doesn't filter out any entites, but if I add the filter it works, why?
Probably something is not correct inside the 'NodeViewsData.php' (originally) named file. You need to check your

    $data['node_field_data']['table']['base']['access query tag'] = 'node_access';

line both the 'node_field_data' and 'node_access' part. (first is a query array, second is a query tag) Then you need to check your hook_query_TAG_alter inside your module file. I.e. if your module named 'hobby' and you changed node_access tag to hobby_access it needs to be look like this: 'hobby_query_hobby_access_alter'.

When I create a new content it doesn't shows up in the list!
This can happen for two reason, one, it doesn't goes into your access table the new permissions. Second the cache wasn't updated this. You need to check the 'postSave' function inside your entity, here new access or current access is updated (with cache too). Make sure this function executed, also check postDelete to make sure at Entity delete you will delete the unnecessary permissions from the table.

πŸ’¬ Support request
Status

Closed: won't fix

Version

8.7 ⚰️

Component
EntityΒ  β†’

Last updated about 11 hours ago

Created by

πŸ‡§πŸ‡ͺBelgium golddragon007

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • πŸ‡ΊπŸ‡ΈUnited States mrweiner

    Anybody who stumbles across this, a way to mimic entity access in the context of views is to actually just check the entity access in views_post_execute(), as in

    function cc_views_post_execute(ViewExecutable $view) {
      $removed_results = 0;
    
      foreach ($view->result as $key => $row) {
        $entity = $row->_entity;
        $access = $entity->access('view', \Drupal::currentUser(), TRUE);
    
        if ($access->isForbidden()) {
          unset($view->result[$key]);
          $removed_results++;
        }
      }
    
      // Update the total rows.
      $view->total_rows -= $removed_results;
      $view->pager->updatePageInfo();
    }
    

    This isn't as robust as what's proposed in this post, but is a very simple way to get a similar result out of views. You'll likely need to do some cache handling for this as well.

Production build 0.71.5 2024