Károly Négyesi

Access Control

Drupal 7

In Drupal 7, a hook_node_access implementation could return NODE_ACCESS_IGNORE, NODE_ACCESS_ALLOW and NODE_ACCESS_DENY. If any of them returned NODE_ACCESS_DENY then access was denied. If neither did but one returned NODE_ACCESS_ALLOW then access was allowed. If neither of these values were returned by any implementation then the decision was made based on other rules but at the end of the day some code needed to grant access explicitly or access was denied. Other entities didn’t have access control.

Also, blocks had some sort of access control in a very strange way: hook_block_list_alter is used -- even by core -- to remove the non-visible blocks.

Drupal 8

Drupal 8 brings a unified entity API -- even blocks become entities. It also uses many of the same objects and concepts for routing access. Instead of constants, we now use objects implementing the AccessResultInterface. You can get an instance by calling the rather self descriptive AccessResult::allowed(), AccessResult::forbidden(), AccessResult::neutral() methods. If you are handed an AccessResultInterface object you can figure out which one it is by calling the isAllowed, isForbidden, isNeutral methods on it. Only one of them can return TRUE. Access results can be cached and so have relevant cache contexts and tags -- this is why we bother with Neutral. This caching metadata is properly merged when doing various operations on access results.

These objects are returned by hook_entity_access implementations for entity access check and by the check method of services tagged with access_check used for routing access

Let’s first consider a node access case. The entity access checker fires hook_entity_access and hook_ENTITY_TYPE_access (in this case hook_node_access) and tries to put together the results in a sane way. If we do not want to change the behavior from D7, then any D7-deny (now called Forbidden) should result in a Forbidden end result. If there are no Forbiddens then any Allowed should result in a final Allowed return value. Finally, if there were neither Forbiddens nor Allows then only Neutral were present (if anything at all) so return a Neutral. This is called the orIf operation, if you have two result objects then run $result1->orIf($result2) to get the end result. The other doesn’t matter, $result2->orIf($result1) is the same.

Let’s take a look at the AccessResult::allowedIfHasPermission method. What results do we want? Obviously, if the permission is present we want an Allowed to be returned. But if the permission is not there? It can not result in Forbidden because then any hook_node_access simply returning the result of this method would immediately deny access to the node. It really can only return Neutral: the lack of a permission means we this method can’t form an opinion about access. (Using this method is strongly preferred to if ($user->hasPermission()) { return AccessResult::allowed();} because it adds the right caching metadata).

Let’s say we want to determine whether a field is editable! This requires the entity to be editable and the field to be editable. For the sake of simplicity, let’s presume both are controlled by a permission. So let’s say the user has “edit entity” permission but doesn’t have the “edit field” permission. So according to the previous paragraph AccessResult::allowedIfHasPermission($account, ‘edit entity’) is Allowed while AccessResult::allowedIfHasPermission($account, ‘edit field) is Neutral. We can not return Allowed in this case! So we can’t use orIf -- we need another operation: this is called andIf. Much like orIf we want any Forbidden input to result in a Forbidden output and again the same as orIf we want two Allowed to result in Allowed. The only difference is in the case detailed above: when one is Allowed, the other Neutral, here orIf results in Allowed but andIf results in Neutral.

If you want to use your knowledge and familiarity with AND/OR then consider first the iron rule of “any Forbidden input results in Forbidden” and only if that rule didn’t apply then consider Allowed as TRUE and Neutral as FALSE and apply the normal AND/OR to these values. This logic is called Kleene's weak three-valued logic where the “third value” is Forbidden. Most misunderstanding and confusion results from trying to treat Forbidden as FALSE instead of being the contagious “third value” it is. The name Neutral might make you think “oh, three values are not a problem, I will just erase any N I see and the resulting two values can be evaluated like normal AND/OR” this is absolutely incorrect! In fact, if you have two variables and both are Allowed / Forbidden then the results for $x->orIf($y) will be the exact same as $x->andIf($y)! The outcome will only differ if either $x or $y is Neutral (and the other is Allowed).

Routing vs Entity Access

We have clarified the necessity for two operators and we have two subsystems, each using one of them: routing uses andIf, entity uses orIf. The difference is subtle -- as we have seen the only difference is the end of result of two access checks where one is Neutral, the other is Allowed. This becomes a Neutral result in routing and an Allowed result in entity access.

Making a Decision

All this three value logic is nice but at the end of the day, this is all internal. The user wants to see a page, can we show it to them or do we show a 403 page? Can we show this entity? The answer cannot be “I do not know”, it must be yes or no. So the system looks at the result and says yes if it is explicitly allowed, otherwise says no. For routing (remember, andIf) this is very simple: if every access checker answered Allowed then and only then we can answer yes. For entity access (remember, orIf) there should be no Forbidden answers and at least one Allowed to say yes.