Drupal 8 - Views Custom Access with a Plugin

Submitted by nigel on Sunday 29th April 2018

Have you ever wanted to prohibit the display of a Drupal 8 View dependent upon some custom rules that don't fit easily with Drupal's permissions model? This tutorial provides the steps needed to achieve such a solution. 

I've encountered such a set of circumstances at two client sites. Client 1, which was a D7 site, needed to allow/prohibit a view being displayed dependent upon the user's country of origin, their work group, and taxonomy of the content. Client 2, which was a D8 site, wanted to show or deny dependent upon an external authentication system external to Drupal - i.e. the D8 authentication wasn't been used at all and all users were anonymous to Drupal, although the external authentication was managed by a cookie. This kind of model is often used in paywall / protected areas of websites. 

To achieve an allow / deny permission model for views with custom logic, we need to create a views Plugin. This will need a custom module. So let's get cracking. 

Starting Point

For this tutorial I decided to create a clean D8 build on my home built VM, and for the first time I elected to move away from my normal drupal/drupal composer template, and give hussainweb/drupal-composer-init a go since it comes with the Drupal Console and local Drush. 

I built out my clean build and enabled the devel module which enabled me to create some dummy data. I then moved the Frontpage view away from <front> and to the url /test. I am going to use this view for my tutorial. 

To target Views access I discovered that the abstract class AccessPluginBase in the D8 API is where we need to focus our attention and it will be this class that is extended. 

Create the custom module
Since I have the Drupal Console installed I decided it would be great to use it to generate the skeleton of my custom module. Alas it doesn't have the capability to create a Views Access plugin too, so we will have to hand craft that. First I created the directory structure:
$ pwd
/var/www/html/clean/docroot/modules
$ mkdir -p custom/views_custom_access
$ cd custom/views_custom_access
Now it's time for the Drupal Console.
$ ../../../../vendor/bin/drupal generate:module
 
 // Welcome to the Drupal module generator
 
 Enter the new module name:
 > views_custom_access
 
 Enter the module machine name [views_custom_access]:
 > ^C
nigel@badzilla-d8 /var/www/html/clean/docroot/modules/custom/custom_views_perms $ ../../../../vendor/bin/drupal generate:module
 
 // Welcome to the Drupal module generator
 
 Enter the new module name:
 > Views Custom Access
 
 Enter the module machine name [views_custom_access]:
 > views_custom_access
 
 Enter the module Path [modules/custom]:
 > 
 
 Enter module description [My Awesome Module]:
 > Plugin for site specific views custom access
 
 Enter package name [Custom]:
 > 
 
 Enter Drupal Core version [8.x]:
 > 
 
 Do you want to generate a .module file? (yes/no) [yes]:
 > 
 
 Define module as feature (yes/no) [no]:
 > 
 
 Do you want to add a composer.json file to your module? (yes/no) [yes]:
 > 
 
 Would you like to add module dependencies? (yes/no) [no]:
 > 
 
 Do you want to generate a unit test class? (yes/no) [yes]:
 > no
 
 Do you want to generate a themeable template? (yes/no) [yes]:
 > no
 
 Do you want proceed with the operation? (yes/no) [yes]:
 > 
 
Generated or updated files
 Generation path: /var/www/html/clean/docroot
 1 - /modules/custom/views_custom_access/views_custom_access.info.yml
 2 - /modules/custom/views_custom_access/views_custom_access.module
 3 - /modules/custom/views_custom_access/composer.json
 
 
 Inline representation of this command:                                                                         
 
 
$ drupal generate:module  --module="Views Custom Access" --machine-name="views_custom_access" --module-path="modules/custom" --description="Plugin for site specific views custom access" --core="8.x" --package="Custom" --module-file --composer --learning --uri="http://default" --no-interaction
 
 
 Yaml representation of this command, usage copy in i.e. `~/.console/chain/sample.yml` to execute using `chain` 
 command, make sure your yaml file start with a `commands` root key:                                            
 
 
commands:
  - command: 'generate:module'                                      
    options:                                                        
        module: 'Views Custom Access'                               
        machine-name: views_custom_access                           
        module-path: modules/custom                                 
        description: 'Plugin for site specific views custom access' 
        core: 8.x                                                   
        package: Custom                                             
        module-file: true                                           
        composer: true                                              
        learning: true                                              
        uri: 'http://default'                                       
 
 
 
 
 Generated lines: "43"
The module is now created. The views_custom_access.info.yml is listed below.
name: 'Views Custom Access'
type: module
description: 'Plugin for site specific views custom access'
core: 8.x
package: 'Custom'
views_custom_access.services.yml
We need to create a services yml file for our plugin, which is listed below. The namespacing of the class was a real gotcha for me and it took a while to get it correct, so pay particular attention to the class: value.
services:
  plugin.manager.viewsaccess:
    class: Drupal\views_custom_access\Plugin\views\access\ViewsCustomAccess
    parent: default_plugin_manager
ViewsCustomAccess.php
The yml file above should have given you some idea to what our next task will be! We have to create the correct directory structure for our plugin class.
$ mkdir -p src/Plugin/views/access
$ cd src/Plugin/views/access
$ touch ViewsCustomAccess.php
Now we can create our plugin code.
<?php


namespace Drupal\views_custom_access\Plugin\views\access;

use 
Drupal\views\Plugin\views\access\AccessPluginBase;
use 
Drupal\Core\Session\AccountInterface;
use 
Symfony\Component\Routing\Route;

/**
 * Class ViewsCustomAccess
 *
 * @ingroup views_access_plugins
 *
 * @ViewsAccess(
 *     id = "ViewsCustomAccess",
 *     title = @Translation("Customised access for views"),
 *     help = @Translation("Add custom logic to access() method"),
 * )
 */
class ViewsCustomAccess extends AccessPluginBase
{
    
/**
     * {@inheritdoc}
     */
    
public function summaryTitle()
    {
        return 
$this->t('Customised Settings');
    }


    
/**
     * {@inheritdoc}
     */
    
public function access(AccountInterface $account)
    {
        return 
FALSE;
    }


    
/**
     * {@inheritdoc}
     */
    
public function alterRouteDefinition(Route $route)
    {
        
// TODO: Implement alterRouteDefinition() method.
    
}
}
?>
Analysis of ViewsCustomAccess.php

There are multiple references in the API documentation and the core codebase to give you a steer if you are struggling here. The first point of interest is the namespacing - which must match what has already been defined in the services files. 

Now look at the class's metadata in the doc heading. The three entries (id, title and help) are required as identified in the AccessPluginBase but this is a little sketchy when it comes to precise syntax. Another reference, which gives a definitive guide to all the possible entries is in class ViewsAccess - and finally on the subject of the metatags, an explanations of Annotations-based plugins is here

My class ViewsCustomAccess must extend AccessPluginBase, and since that is an abstract class with a requirement for the methods access and alterRouteDefinition, they must be defined in the class too. 

The access method is where the custom logic can be added, and it needs to return a Boolean depending upon whether the plugin is going to allow or deny access to the view. 

The alterRouteDefinition is used to add new permission or role based requirements - but since I don't need either for my use case, it is left blank. 

Enable the custom module
You should all know how to do this already, but hey, here it is to cut and paste.
$ drush en views_custom_access -y          
 [success] Successfully enabled: views_custom_access
Configure views
Select Permissions
Custom
Access Denied

Now navigate to the Frontpage views edit page, and you should see a screen similar to the first screenshot above. I have highlighted where the access permissions - click here. You should see our new plugin appear in the selection modal - select it and apply it to all displays. Now make sure you save the view, and navigate to the view's url which in my case is /test, and you should see an access denied (third screenshot) since I haven't added anything to my alterRouteDefinition() method yet. This needs to set our custom access rules. 

Add new permissions
We now need to define our access permissions in alterRouteDefinition() by using the method setRequirement() from the passed parameter route. I took my inspiration from core/modules/views/src/Plugin/views/access/None.php which defines the access rules for no access control at all.
<?php
    
/**
     * {@inheritdoc}
     */
    
public function alterRouteDefinition(Route $route)
    {
        
$route->setRequirement('_access''TRUE');
    }
?>
Add custom access business logic
Next we need to define our own business logic for determining whether our user has the access we just defined. The code goes in the Access() method and must return a Boolean. I have added some spurious logic - I pick a random number which is either 0 or 1 which I return and therefore if it's 0 I don't display the view and if it's 1 I do. Simple.
<?php
    
/**
     * {@inheritdoc}
     */
    
public function access(AccountInterface $account)
    {
        
// Spurious logic check
        
$ret rand(01);
        
$message $ret == 'won\'t' 'will';
        \
Drupal::messenger()->addMessage($ret ' so ' $message ' display the view');

        return 
$ret;
    }
?>
The perennial Drupal 8 caching problem...

Unfortunately we aren't done yet. My particular use case is I must always make a decision whether I show the view or not to anonymous traffic when they get to access(). That means page caching is out of the question and has to be disabled otherwise my anonymous traffic will always get the same output and won't ever hit access(). As it stands however, our solution won't work. I discovered that it will work providing access() always returns TRUE. It won't work again once FALSE is returned  - i.e. the user will never hit access() - until I clear the caches. That is clearly unacceptable. It appears that render caching is the culprit here and it's very difficult to fix. 

There are a number of approaches here - all I tried - and none of the listed ones worked. 

  • Don't extend with AccessPluginBase - use Permission instead. 
  • Use AccessPluginBase and implements CacheableDependencyInterface
  • Define getCacheMaxAge and return 0. 
  • Using cache tags
  • Using cache contexts
  • Using hook_views_pre_view

All of them had the same problem - the render cache for anonymous users is never deleted or invalidated. 

A terrible solution that will work
Since the problem exists in the render cache, could we not just turn off the render caching entirely for the site? Well yes, but the performance of the site will be severely compromised. To check that this solution will work, add the following to the bottom of the
sites/default/settings.php
<?php
$settings
['container_yamls'][] = DRUPAL_ROOT '/sites/development.services.yml';
$settings['cache']['bins']['render'] = 'cache.backend.null';
$settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.null';
?>
But we really don't want to do that..
A good solution

Ok - so let's think about this. We want the code to always hit access() in the ViewsCustomAccess Plugin, even if the user is anonymous, and even if for a user it returns FALSE. It can never be render cached. So if we delete the render cache of that particular view for that particular user on page load it will have to be recreated. 

So the solution is this - develop an Event Subscriber that is triggered on a Kernel Response. This will determine the cache identifier for the view's render, and delete it. 

Creating the Event Subscriber
We need to create the directory structure. Make sure you are in the src directory.
$ mkdir EventSubscriber
$ cd EventSubscriber
$ touch ViewsCustomAccessSubscriber.php
Now drop in the following code

<?php


namespace Drupal\views_custom_access\EventSubscriber;



use 
Symfony\Component\EventDispatcher\EventSubscriberInterface;
use 
Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use 
Symfony\Component\HttpKernel\KernelEvents;


class 
ViewsCustomAccessSubscriber implements EventSubscriberInterface
{
    
/**
     * {@inheritdoc}
     */
    
public static function getSubscribedEvents()
    {
        
$events[KernelEvents::RESPONSE][] = ['extendViewsCustomAccess'];
        return 
$events;
    }

    
/**
     * Perform our logic here
     *
     * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
     */
    
public function extendViewsCustomAccess(FilterResponseEvent $event)
    {
        
// Get the current user
        
$user = \Drupal::currentUser();
        
// Get the user's permissions hash
        
$hash = \Drupal::service('user_permissions_hash_generator')->generate($user);

        
// Get the render cache
        
$render_cache = \Drupal::cache('render');
        
// Use the hash to delete the cached render for the view.
        // @TODO instead of hardcoding, create a facility to read all views using my permission plugin and loop through after drush cr
        
$render_cache->delete('view:mytest:display:page_1:[languages:language_interface]=en:[theme]=bartik:[user.permissions]='.$hash);
    }
}
?>
And add in this new service at the bottom of views_custom_access.services.yml
  viewsaccess.subscriber:
    class: Drupal\views_custom_access\EventSubscriber\ViewsCustomAccessSubscriber
    tags:
      - { name: 'event_subscriber' }
Analysis of the Event Subscriber
What we are doing is getting the unique (per uid) permission hash which is used in the last part of the cache identifier in the render cache. We then append it to the first part of the identifier which includes the view, its display, and its language. At the moment that part of the code is hard-coded BUT it would be possible to loop around all the site's views that are using our custom permissions, and delete all the render caches for a user at once. Also note we don't bother checking if a cache identifier exists before we delete - there is no point. The performance hit is minimal because most sites will be backending their cache with Memcached or Redis which are lightning fast.
Conclusion
Show Content
Don't show content

The two images come from our solution - refresh the screen and we'll get one of the two images on a 50% chance basis. 

In the end this was a hard fought victory. The problem with Drupal is it doesn't make life easy for those who authenticate outside of the Drupal ecosystem and hit the site with anonymous traffic that needs to see or not see content dependent of external endpoint results. Drupal 8 is built for speed with complex cache layering that is optimised to serve cached content by default for anonymous users. If you don't want default, you will get a battle.