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.
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.
$ pwd /var/www/html/clean/docroot/modules $ mkdir -p custom/views_custom_access $ cd custom/views_custom_access
$ ../../../../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"
name: 'Views Custom Access' type: module description: 'Plugin for site specific views custom access' core: 8.x package: 'Custom'
services:
plugin.manager.viewsaccess:
class: Drupal\views_custom_access\Plugin\views\access\ViewsCustomAccess
parent: default_plugin_manager$ mkdir -p src/Plugin/views/access $ cd src/Plugin/views/access $ touch ViewsCustomAccess.php
<?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.
}
}
?>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.
$ drush en views_custom_access -y [success] Successfully enabled: views_custom_access
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.
<?php
/**
* {@inheritdoc}
*/
public function alterRouteDefinition(Route $route)
{
$route->setRequirement('_access', 'TRUE');
}
?><?php
/**
* {@inheritdoc}
*/
public function access(AccountInterface $account)
{
// Spurious logic check
$ret = rand(0, 1);
$message = $ret == 0 ? 'won\'t' : 'will';
\Drupal::messenger()->addMessage($ret . ' so ' . $message . ' display the view');
return $ret;
}
?>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.
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';
?>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.
$ mkdir EventSubscriber $ cd EventSubscriber $ touch ViewsCustomAccessSubscriber.php
<?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);
}
}
?> viewsaccess.subscriber:
class: Drupal\views_custom_access\EventSubscriber\ViewsCustomAccessSubscriber
tags:
- { name: 'event_subscriber' }
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.