Advanced techniques for route access control in Drupal 8
Drupal 8 is very flexible when it comes to controlling access to your routes. It inherits quite a bit from the Symfony routing system, but adds its own flavour on top of that. In this article we are going to look at an example of a complex access requirement. In doing so, we won't cover the simpler use cases which are already described in the Drupal.org docs, but we will sure make use of some of them.
The requirement
So let's imagine this scenario: we have two types of users (employees and managers) whose persona is not determined by a user role. Let's say their "role" is determined on the fly as a result of an API call or some dynamic thing.
Now, let's say we have 3 routes: Route A (accessible for employees only), Route B (accessible for managers only) and Route C (accessible for both).
Finally, imagine we have a service called UserType
which we can ask what type of person the current user is.
Implementation
One of the cool things about the Route access control in Drupal 8 is the ability, as the docs show, to delegate the access checking to a service. So a basic implementation for Route A and Route C can be something like this.
my_module.route_a:
path: 'route-a'
defaults:
_controller: '\Drupal\my_module\Controller\DefaultController::buildRouteA'
_title: 'Route A'
requirements:
_company_access_check_employee: 'true'
This is the route definition. As you can see, as per the docs, we have a requirement for the company_access_check
access service to return the access result. So let's quickly see that service:
my_module.company_access_check:
class: Drupal\my_module\Access\CompanyAccessCheck
arguments: ['@user_type']
tags:
- { name: access_check, applies_to: _company_access_check_employee }
A simple tagged service definition with a dependency to our fictitious UserType
service that tells us the type of person the current user is. Additionally, we specify that this access checking service should be applied to all routes with the requirement _company_access_check_employee
.
I am not going to show you this class because an example is already covered in the docs. However, it has one method called access()
which by default gets passed the AccountInterface
of the current user. So with the help of our UserType
service we can determine whether the current user is an employee. Then we can return either AccessResult::forbidden()
or AccessResult::allowed()
.
For managers, we do the same: create a new service and apply it to Route C.
So where does the complication come? Well, you guessed it: Route B which requires both. If we add two requirements to the route, let's say something like this:
my_module.route_b:
path: 'route-b'
defaults:
_controller: '\Drupal\my_module\Controller\DefaultController::buildRouteB'
_title: 'Route B'
requirements:
_company_access_check_employee: 'true'
_company_access_check_manager: 'true'
It will check for both but grant access only if both return positive. So in our case this won't be very helpful since we need to check if the user is either. For the purposes of this article, please forgive the implication that managers are not also employees.
The solution
What we can do is create another access service called something like company_access_check_both
which is responsible for determining if the current user is of one of the user roles. This is fine if our requirements are as simple as we described them. But what happens when we have multiple user types and a bunch of different routes where we have to mix and match the user types which have access to them? Creating a service for all these different types of combinations is not very efficient.
So instead, let's create a generic service called company_access_check_multiple
AND specify in the route the type of user that has access to it in the form of a custom option. For example, the route definition can be something like this:
my_module.route_b:
path: 'route-b'
defaults:
_controller: '\Drupal\my_module\Controller\DefaultController::buildRouteB'
_title: 'Route B'
requirements:
_company_access_check_multiple: 'true'
options:
_company_access_users:
- Employee
- Manager
In this route we created a custom option called _company_access_users
in which we list the types of users that should have access to it.
But how can we make use of this inside our service? Well, the Route object can be inspected and the list of allowed user types can be retrieved:
$types = $route->getOption('_company_access_users');
So if the route has that option, $types
will tell us what type the current user needs to be in order to have access.
However, where do we get the Route object? As we know, the access()
method of the service only receives the user account as a parameter. We might be tempted to inject the current route match service into our own. This does the trick, but only when the route in question is being checked upon a user actually going to it. It will miserably fail when a given route is being checked for access from another one (for example when building menu links).
If we dig deep and look closely, before our access()
method is called, an arguments resolver is employed via the AccessArgumentsResolverFactory
. This allows for the current user account to be passed to the access()
method. But what not many people know is that if we type hint our access()
method with either Route
, RouteMatchInterface
or Request
, we will be getting those parameters as well. And in this case, the Route
object is that of the route being checked for access rather than the current route.
So something like this:
public function access(AccountInterface $account, Route $route) {
$types = $route->getOption('_company_access_users');
// etc
}
So there you have it. A neat little trick that opens the door to some complex access restriction rules on your routes.