We need all the benefits of the Node entityoften, but pages of some content types (and only those pages) must be inaccessible for anonymous or non-editor users.

In this article I will show you a way to accomplish this feature.

Create the module skeleton

I will use the canonical_node namespace in this example. We will need an info file. And since this feature relies on other things that are available only on an installed Drupal instance, we will write a PHPUnit browser test to make sure everything works as designed.

To write the test we have to know exactly what we want to achieve. The acceptance criteria:

  • We want to grant access to the canonical route of the special_node_type nodes only if the user is permitted to view and update the content.
  • We want to allow viewing the given type of nodes in every other case, so if the node would be displayed somewhere else (irrespective of the chosen view mode), it should be displayed.
  • Other node types should not be affected by this change.

This will be our test (tests/src/Functional/CanonicalNodeAccessTest.php):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?php

namespace Drupal\Tests\canonical_node\Functional;

use Drupal\Tests\BrowserTestBase;

/**
 * Tests node canonical route access modifications.
 *
 * @group node
 */
class CanonicalNodeAccessTest extends BrowserTestBase {

  /**
   * Test nodes.
   *
   * @var \Drupal\node\NodeInterface[]
   */
  protected $testNodes;

  /**
   * Modules to enable.
   *
   * @var array
   */
  public static $modules = ['views', 'node', 'canonical_node'];

  /**
   * {@inheritdoc}
   */
  protected function setUp() {
    parent::setUp();

    foreach (['special_node_type', 'basic'] as $node_type_id) {
      $this->drupalCreateContentType(['type' => $node_type_id]);

      $node = $this->createNode(['type' => $node_type_id]);
      $node->save();
      $this->testNodes[] = $node;
    }
  }

  /**
   * Tests node canonical route access.
   */
  public function testNodeAccess() {
    // Test anonymous access.
    $this->doTestNodeAccess();

    // Test logged-in, but non-editor user access.
    $this->drupalLogin($this->drupalCreateUser(['access content']));
    $this->doTestNodeAccess();

    // Test editor user access.
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
      'create special_node_type content',
      'edit any special_node_type content',
    ]));
    $this->doTestNodeAccess(TRUE);

    // Test admin user access.
    $this->drupalLogin($this->rootUser);
    $this->doTestNodeAccess(TRUE);
  }

  /**
   * Tests that the current user gets the proper node page access permissions.
   *
   * @param bool $should_access_special_canonical
   *   Whether the current user should access the special node's canonical
   *   route.
   */
  protected function doTestNodeAccess($should_access_special_canonical = FALSE) {
    $this->drupalGet($this->testNodes[0]->toUrl());
    $this->assertSession()->statusCodeEquals($should_access_special_canonical ? '200' : '403');
    $this->drupalGet($this->testNodes[1]->toUrl());
    $this->assertSession()->statusCodeEquals('200');
    $this->drupalGet('/node');
    $this->assertSession()->pageTextContains($this->testNodes[0]->label());
    $this->assertSession()->pageTextContains($this->testNodes[1]->label());
  }

}

In the setup, we create two node types and one node with each node type. One of them is the special type that should be managed specially, the other one is a regular node type that shouldn’t be affected.

The doTestNodeAccess method is needed only for making the test DRY.

Implementation

The easiest way to make a preexisting route access stricter is adding an additional requirement for the route. This can be solved by a route subscriber service. In the route subscriber we will add a custom access requirement to the entity.node.canonical route.

Let’s register the route subscriber and the access check service in the canonical_node.services.yml file:

1
2
3
4
5
6
7
8
9
10
services:
  canonical_node.route_subscriber:
    class: Drupal\canonical_node\Routing\CanonicalNodeRouteSubscriber
    tags:
      - { name: event_subscriber }

  canonical_node.access_checker:
    class: Drupal\canonical_node\Access\CanonicalNodeRouteAccessCheck
    tags:
      - { name: access_check, applies_to: _canonical_node_access_check }

Now we have to create the route subscriber src/Routing/CanonicalNodeRouteSubscriber.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

namespace Drupal\canonical_node\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

/**
 * Listens to the dynamic route events.
 */
class CanonicalNodeRouteSubscriber extends RouteSubscriberBase {

  /**
   * {@inheritdoc}
   */
  public function alterRoutes(RouteCollection $collection) {
    if ($route = $collection->get('entity.node.canonical')) {
      $route->setRequirement('_canonical_node_access_check', 'TRUE');
    }
  }

}

And finally, add the class for our new access check service class src/Access/CanonicalNodeRouteAccessCheck.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace Drupal\canonical_node\Access;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\node\NodeInterface;

/**
 * Access check for node canonical page.
 */
class CanonicalNodeRouteAccessCheck implements AccessInterface {

  /**
   * Access check for node canonical page.
   *
   * Allows accessing node canonical page only if the current user can view AND
   * edit the node.
   *
   * @param \Drupal\node\NodeInterface $node
   *   The node object from the 'entity.node.canonical' route slug.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The access result.
   */
  public function access(NodeInterface $node) {
    if ($node->bundle() === 'special_node_type') {
      return AccessResult::allowedIf($node->access('update') && $node->access('view'))
        ->cachePerUser()
        ->addCacheableDependency($node);
    }

    return AccessResult::allowed();
  }

}

You are probably asking why I return with Accessresult::allowed() for every other node type. This is required because

If a route has multiple access checks, the andIf operation is used to chain them together: all results must be AccessResult::allowed otherwise access will be denied.

If you run the test, you will see that the feature works properly.

Alternatives

You can improve the module with a configuration that stores the specially-handled entity types, or you may refactor the route subscriber and the access check service to make them work for any kinds of entity types.

You can also skip creating the custom access check service by just setting a _custom_access requirement for the route, but that way you can’t be sure that you won’t override a previously defined custom requirement (or you have to figure out what to do in that case).

An another option is to extend the default node access control handler with a new operation view_canonical, change the node entity to use the new handler and in the route subscriber just switch the value of the original _entity_access from node.view to node.view_canonical. But this solution also requires the certainty that the handler isn’t and won’t be changed by any other module.

Happy coding!


Sources: