Problem/Motivation

This is a child issue of #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees.

Unfortunately, because Drupal only wants to show menu links to a user that are accessible for that user, rendering menus presents a complex performance problem. A menu consists of menu links arranged in a tree. Each menu link points to a route (or an external URL). Each route can have any number of access checks defined. And, finally, those access checks can be of arbitrary complexity, and therefore of arbitrary cacheability. Some may be globally cacheable, others per role, yet others per user, per page, per language, and whatnot.

But we currently don't even have that cacheability metadata!

This issue is about adding such cacheability metadata.

Footnote

Note: we currently are caching rendered menu trees, but as #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees says:

#2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags enabled render caching of menu blocks, but wrongly assumed that all menu links can be cached per role. Current HEAD is hence broken in that regard (steps to reproduce: create two non-admin users of the same role, give them the "view own unpublished content" permission, let create user A an unpublished node, put that in a menu that's in a menu block, look at that menu block with user B and you'll see the link to the unpublished node).

Proposed resolution

Allow access results to specify if they are cacheable, and if so, which cache contexts (@cache_context.url, @cache_context.language, @cache_context.theme, @cache_context.user, @cache_context.user.roles, etc.) its results should be varied by.

We can do this by making access results objects rather than the various options of "boolean" or "boolean or null" or "one of three string constants" or "one of three integer constants" that we have today. Those access results objects can then provide cacheability — if they want — by also implementing CacheableInterface (which already exists in HEAD).

Remaining tasks

  1. Build consensus
  2. Review
  3. Commit

User interface changes

None.

API changes

  1. Addition: \Drupal\Core\Access\AccessResultInterface — the entire patch is centered around this.
  2. Addition: \Drupal\Core\Access\AccessResult, which implements AccessResultInterface and CacheableInterface. Because it implements CacheableInterface, it can provide cacheability metadata. However, other access checks can choose to use another AccessResultInterface implementation that does not implement CacheableInterface — those will be treated as uncacheable.
    Before
        if ($route->getRequirement('_access') === 'TRUE') {
          return static::ALLOW;
        }
        elseif ($route->getRequirement('_access') === 'FALSE') {
          return static::KILL;
        }
        else {
          return static::DENY;
        }
    
    After
        if ($route->getRequirement('_access') === 'TRUE') {
          return AccessResult::allowed();
        }
        elseif ($route->getRequirement('_access') === 'FALSE') {
          return AccessResult::forbidden();
        }
        else {
          return AccessResult::create();
        }
    
  3. Deletion: \Drupal\Core\Access\AccessInterface — this only served as a home for the ALLOW/DENY/KILL constants, which were the possible return values in many cases before this patch. Since we now have AccessResultInterface instead, they've become obsolete.
  4. Converting all the access-checking logic to AccessResultInterfacethis clearly shows how inconsistent access checking is today in HEAD:
    1. TRUE/FALSEAccessResultInterface
      1. \Drupal\Core\Access\AccessManagerInterface::check()
      2. \Drupal\Core\Access\AccessManagerInterface::checkNamedRoute()
      3. \Drupal\Core\Entity\EntityAccessControlHandlerInterface::checkAccess()
      4. \Drupal\Core\Entity\EntityAccessControlHandlerInterface::checkCreateAccess()
      5. \Drupal\Core\Field\FieldItemListInterface::defaultAccess()
      6. \Drupal\content_translation\ContentTranslationHandlerInterface::getTranslationAccess()
      7. \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface::accessEditEntityField()
      8. shortcut_set_edit_access()
      9. shortcut_set_switch_access()
      Before
      $access = $access_manager->check(...);
      if ($access) {
        ...
      }
      
      After
      $access = $access_manager->check(...);
      if ($access->isAllowed()) {
        ...
      }
      
    2. AccessInterface::(ALLOW|DENY|KILL)AccessResultInterface:
      1. \Drupal\Core\Access\AccessibleInterface::access() (also affects FieldItemListInterface and EntityInterface, which extend this interface)
      Before
      $form[$name]['#access'] = $items->access('edit');
      
      After
      $form[$name]['#access'] = $items->access('edit')->isAllowed();
      
    3. NODE_ACCESS_ALLOW/NODE_ACCESS_DENY/NODE_ACCESS_IGNOREAccessResultInterface:
      1. hook_node_access()
    4. TRUE/FALSE/NULLAccessResultInterface
      1. hook_block_access()
      2. hook_entity_access()
      3. hook_entity_create_access()
      4. hook_ENTITY_TYPE_access()
      5. hook_ENTITY_TYPE_create_access()
      6. hook_entity_field_access()
      7. \Drupal\node\NodeGrantDatabaseStorageInterface::access()
  5. The following access checking logic used to return FALSE (the equivalent of AccessResult::forbiden()) in case it didn't care about an operation (e.g. the file access control handler only handled the "download" operation and returned FALSE in all other cases). But this is bad, because it prevents contrib modules implementing access checking logic for those other operations. Therefore, now AccessResult::create() is returned, to allow contrib modules to affect the access checking logic, for the following:
    1. LanguageAccessControlHandler
    2. shortcut_set_edit_access()
    3. shortcut_set_switch_access()
    4. ShortcutSetAccessControlHandler
    5. UserAccessControlHandler
    6. FileAccessControlHandler.
    7. MenuLinkContentAccessControlHandler

This effectively allows every access check to provide cacheability metadata, the overwhelming majority of which is then cacheable. Notably, this allows entity access checks to be cacheable (typically per role)!

CommentFileSizeAuthor
#106 interdiff.txt1.51 KBWim Leers
#106 access_cacheability_metadata-2287071-106.patch264.42 KBWim Leers
#105 interdiff.txt6.8 KBWim Leers
#105 access_cacheability_metadata-2287071-105.patch264.3 KBWim Leers
#104 2287071.104.patch262.26 KBalexpott
#104 100-104-interdiff.txt5.55 KBalexpott
#100 2287071_100.patch264.04 KBchx
#96 interdiff-callers_updates.txt69.73 KBWim Leers
#96 interdiff-interface_and_implementation_changes.txt39.83 KBWim Leers
#96 interdiff.txt107.74 KBWim Leers
#96 access_cacheability_metadata-2287071-96.patch271.21 KBWim Leers
#83 interdiff.txt2.34 KBWim Leers
#83 access_cacheability_metadata-2287071-83.patch300.1 KBWim Leers
#80 interdiff.txt21.08 KBWim Leers
#80 access_cacheability_metadata-2287071-80.patch299.93 KBWim Leers
#79 interdiff.txt12.72 KBWim Leers
#79 access_cacheability_metadata-2287071-79.patch298.49 KBWim Leers
#74 interdiff.txt843 bytesWim Leers
#74 access_cacheability_metadata-2287071-74.patch299.01 KBWim Leers
#71 interdiff.txt1.04 KBWim Leers
#71 access_cacheability_metadata-2287071-71.patch298.82 KBWim Leers
#69 access_cacheability_metadata-2287071-69.patch289.46 KBWim Leers
#62 interdiff.txt40.22 KBWim Leers
#62 access_cacheability_metadata-2287071-61.patch299.54 KBWim Leers
#57 access_cacheability_metadata-2287071-57.patch290.43 KBWim Leers
#55 interdiff.txt1.04 KBWim Leers
#55 access_cacheability_metadata-2287071-55.patch290.41 KBWim Leers
#51 interdiff-test-coverage.txt21.88 KBWim Leers
#51 interdiff-renaming.txt99.66 KBWim Leers
#51 access_cacheability_metadata-2287071-51.patch289.42 KBWim Leers
#48 interdiff.txt1.21 KBWim Leers
#48 access_cacheability_metadata-2287071-48.patch277.47 KBWim Leers
#46 xhprof runs.zip332.39 KBWim Leers
#45 access_cacheability_metadata-2287071-45.patch276.01 KBWim Leers
#44 interdiff.txt323.28 KBWim Leers
#44 access_cacheability_metadata-2287071-44.patch276 KBWim Leers
#43 access_cacheability_metadata-2287071-43.patch305.51 KBWim Leers
#28 access_cacheability_metadata-2287071-28.patch298.9 KBWim Leers
#17 interdiff.txt1.15 KBWim Leers
#17 access_cacheability_metadata-2287071-17.patch298.89 KBWim Leers
#15 access_cacheability_metadata-2287071-13.patch298.61 KBWim Leers
#12 access_cacheability_metadata-12-WIP-do-not-test.patch90.65 KBWim Leers
#9 interdiff.txt103.04 KBWim Leers
#9 access_cacheability_metadata-9-WIP-do-not-test.patch92.42 KBWim Leers
#1 access_cacheability_metadata-2287071-1.patch43.06 KBWim Leers
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Wim Leers’s picture

Assigned: Unassigned » Wim Leers
Status: Active » Needs review
FileSize
43.06 KB
Wim Leers’s picture

Issue tags: +Needs tests
Wim Leers’s picture

Issue summary: View changes
pwolanin’s picture

A bunch of the code comments reference "this and any parent". Is that carried over from the menu links patch?

I'm not sure what parent means in terms of the general access check API

Wim Leers’s picture

#4:

+  /**
+   * Returns the cache tags that this and any parent should be associated with.
+   *
+   * @return array
+   *  An array of cache tags.
+   */
+  public function getCacheTags();
+

This is not related to the menu system in any way. It refers to parent things/objects.

For example: a certain complex value to calculate may depend on access check results. In that case, it should also be varied by the cache contexts of the access checks that it depends upon, otherwise the result will be incorrect. i.e. if the complex value is in principle globally cacheable, but it depends on e.g. a per-role access check, then the result should be cached per role.

Applied to menu tree render caching: a rendered menu tree depends on the access checks associated with the menu links in the tree. Some links may be cacheable per role, others per user, yet others per theme. That means that the cached result should be varied per role *and* user *and* theme.

Hence: "the cache contexts that this and any parent should be varied by".

pwolanin’s picture

In that case, "parent" doesn't really seem like the right term. Or at least I don't know if we're using it elsewhere in the caching system?

I might use a word like "encompassing" or "enclosing" or maybe "aggregate"?

I'm not sure how to concisely express "other cache items that need to be invalidated if the cached result of this service is invalidated"

msonnabaum’s picture

I think "parent" makes sense here, but we might qualify it a bit. Maybe parent cacheable? None of the other suggestions in #6 are clearer.

One thing that's not clear to me, is how we deal with changes to the cache contexts. For example, if an access check adds by-role to it's parent's contexts, and a permission is added to that role, is the parent's cache tagged with that role so that it'll get cleared? Maybe that's a larger issue than what this patch is doing.


interface CacheabilityAffectorInterface {

I winced at that name at first, but I can't really think of anything better at the moment.

In general, I think this patch is going in the right direction.

effulgentsia’s picture

Huge +1 for adding API support to make access checks cacheable!

Addition: \Core\Cache\CacheabilityAffectorInterface

Why do we need a new interface for this issue instead of using CacheableInterface? The difference seems to be an assumption that we'll only want to cache access checks as part of some bigger structure (e.g., a rendered menu), but that's an implementation detail that shouldn't leak to the access checkers themselves. There's no reason we couldn't choose to cache access check results on their own, in a {cache_access} bin, if we wanted to. And if a containing item does want/need to affect its own cache info with the contained item's cache info, is there any reason it needs the contained item to report its info via a differently named interface?

Ideally, we'd be able to also let access checks specify cache tags — the results of some access checks, like BookNodeIsRemovableAccessCheck and EntityAccessCheck, depends on entities. But because access checks aren't instantiated per unique access check (e.g. per node for which access is being checked), we cannot collect the corresponding cache tags.

What if we do this: instead of making AccessInterface extend CacheableInterface, give it a new method (e.g., getCacheInfo($route, $request, $account)). This needs to return an object that implements CacheableInterface. Instead of requiring each such access checker to create its own unique class for this, perhaps we should add a \Drupal\Core\Cache\CacheableInfo class that's just a dumb value object that receives keys, tags, etc. in its constructor, so that getCacheInfo() implementations can instantiate this class with the desired arguments.

I think it's preferable to tackle this in a follow-up issue, because it'll require relatively invasive changes to access checkers AFAICT.

If the above seems like a good idea, let's do it in this issue, rather than making AccessInterface extend CacheableInterface here, and then undoing it later.

Wim Leers’s picture

FileSize
92.42 KB
103.04 KB

#7:

One thing that's not clear to me, is how we deal with changes to the cache contexts. For example, if an access check adds by-role to it's parent's contexts, and a permission is added to that role, is the parent's cache tagged with that role so that it'll get cleared? Maybe that's a larger issue than what this patch is doing.

Great remark. That's indeed how it's intended to work, but setting that role cache tag (in your example) has not yet been done.
This is exactly what I was getting at 4 months ago over at #2158003-30: Remove Block Cache API in favor of blocks returning #cache with cache tags:

Keeping cache granularities, having them affect cache keys & cache tags

Overall, it looks like it might be better to not merge "cache granularity" with "cache keys"? When caching, "cache granularity" should indeed be converted to "cache keys". But there's an important distinction:

  1. Cache tags need to be bubbled up, because they indicate "what the contained things are", so if a parent gets cached, then it must be tagged with the union of all nested cache tags.
  2. Cache keys, on the other hand, don't need to be bubbled up — they couldn't be, it'd be nonsensical. Only the current thing knows *what* it is and *how* to generate a unique key.
  3. Cache granularity is something in between cache tags and cache keys. Like cache tags, they need to be bubbled up, because if something grandchild in the current render array needs to be per-user, then the grandparent needs to be per-user too! And because it needs to be per-user, it must also affect the cache key (if not, then something cached for user A would be served to user B). However … and we haven't addressed this yet … it should in fact also affect the cache tags: if the granularity includes CACHE_PER_ROLE, then changing anything about the role might cause this cache entry to be stale. The mechanism for that is cache tags.

So, in short:

  1. Cache tags: for cache invalidation.
  2. Cache keys: for cache lookup.
  3. Cache granularity: for request contexts, which affects both cache invalidation and cache lookup.

What we chose to do there back then, is to put the burden of setting a cache context (a special key, essentially) and its corresponding cache tag (if any) manually. We probably want to do that here also, but this is another case of where it'd be great to have cache context affect both the keys and the tags…


#8:

Huge +1 for adding API support to make access checks cacheable!

:)

Why do we need a new interface for this issue instead of using CacheableInterface? The difference seems to be an assumption that we'll only want to cache access checks as part of some bigger structure (e.g., a rendered menu), but that's an implementation detail that shouldn't leak to the access checkers themselves. There's no reason we couldn't choose to cache access check results on their own, in a {cache_access} bin, if we wanted to. And if a containing item does want/need to affect its own cache info with the contained item's cache info, is there any reason it needs the contained item to report its info via a differently named interface?

You make good points :)

However… there is still at least one important conceptual mismatch between cacheable access check results and CacheableInterface: CacheableInterface is designed to be implemented by objects that themselves are cacheable. Hence they specify the cache keys that will be combined into a cache ID. In the case of access checkers, we could indeed make the assumption that it would be okay for the result to be cached. But we definitely also need the ability to affect cacheable parent objects. But "cache keys for caching the results" (i.e. generate a CID) versus "cache keys for affecting cacheable parent objects" (i.e. alter a CID to vary by some things) are not the same.
In #2158003: Remove Block Cache API in favor of blocks returning #cache with cache tags, we removed "cache granularity", in favor of placeholder keys/constants that are expanded into their final value — because that's what needs to happen eventually anyway: the granularities caused a different CID to be generated. That change has been fine so far. But here we do need that distinction again: the keys that determine the CID, versus the keys that affect the CID, to vary it by certain factors/contexts/granularities/variations/whatever you call it.

If we say that access checks results could be cached in the future, then that implies that each of them should provide the necessary keys to generate a CID. We could do that, even though it'd be completely useless (at least for now), but even then, we'd still need to distinguish between "CID keys" versus "variation keys".


What if we do this: instead of making AccessInterface extend CacheableInterface, give it a new method (e.g., getCacheInfo($route, $request, $account)). This needs to return an object that implements CacheableInterface. Instead of requiring each such access checker to create its own unique class for this, perhaps we should add a \Drupal\Core\Cache\CacheableInfo class that's just a dumb value object that receives keys, tags, etc. in its constructor, so that getCacheInfo() implementations can instantiate this class with the desired arguments.

I think it's preferable to tackle this in a follow-up issue, because it'll require relatively invasive changes to access checkers AFAICT.

If the above seems like a good idea, let's do it in this issue, rather than making AccessInterface extend CacheableInterface here, and then undoing it later.

I *do* think that's a good idea. It's essentially taking this patch "all the way". So I worked on that all day today.

This is my concrete proposal:

  1. To address the concerns regarding CacheabilityAffectorInterface, I removed that completely. CacheableInterface also doesn't work because that requires the additional metadata that the access check depends upon (e.g. a node) would be missing, since it's only passed to the ::access() method of an access checker. Hence another reason for AccessCheckResult. I propose to capture the cacheability metadata instead in a Cacheable (better name welcome) value object, that stores whether the associated thing is cacheable at all, the keys, tags, bin, max age and… stores the cache contexts separately, which hence allows us to fix/sidestep the "CID keys" versus "variation keys" problem.
  2. Creating a separate getCacheInfo() method is flawed though, because that would mean replicating the majority of the logic in many access checkers to actually get and validate the entity from the request attributes, for example. So, instead, I propose to create a new AccessCheckResult value object, with two properties: one for the actual value (AccessInterface::(ALLOW|DENY|KILL)), and one for the cacheability metadata.
  3. For this to work, we must also update entity/node access to provide cacheability metadata, otherwise EntityAccessCheck and friends will still not be cacheable. The nice effect of going "all the way" is that we can effectively make entity access checks cacheable! (If the actual entity access hooks on a site *are* cacheable, of course — I only mean to say that it now becomes *possible* to cache them, rather than utterly impossible by design.)

Attached is a WIP patch that implements this. All the changes are trivial, it's just a lot of grunt work. I would appreciate feedback on the overall approach. (This essentially means redoing the patch completely, so the interdiff is rather useless; just providing it for completeness.)

effulgentsia’s picture

But "cache keys for caching the results" (i.e. generate a CID) versus "cache keys for affecting cacheable parent objects" (i.e. alter a CID to vary by some things) are not the same...If we say that access checks results could be cached in the future, then that implies that each of them should provide the necessary keys to generate a CID. We could do that, even though it'd be completely useless (at least for now), but even then, we'd still need to distinguish between "CID keys" versus "variation keys".

I think you make a good point in theory, but our only current implementations of getCacheKeys() in HEAD (blocks) do not follow your model. They only return the variation keys, and BlockViewBuilder::viewMultiple() adds in the rest that's needed for a full CID. So with that as our example, my interpretation of CacheableInterface::getCacheKeys() is that it's de facto the same as what you conceptualize as CacheAffectorInterface::getCacheContexts(), and that it is the invoker of ->getCacheKeys() whose responsible for adding the rest of CID when wanting to cache the item in its own entry. Maybe we want to change that interpretation, but that seems like a separate issue.

Creating a separate getCacheInfo() method is flawed though, because that would mean replicating the majority of the logic in many access checkers to actually get and validate the entity from the request attributes, for example.

No. Access checkers have no business validating request attributes. I think the ones we have that still do that (in e.g., quickedit) have @todos to fix that. Meanwhile, lumping cache info into the access() method means that we'll never be able to cache access check results on their own. For something like a per-user node access based on node grants, we could return the cacheability info faster than a full access check result. Maybe the DX gain of putting it all into access() as you suggest is worth giving up the ability to cache access checks on their own though. @catch, @msonnabaum: what do you think?

Wim Leers’s picture

I think you make a good point in theory, but our only current implementations of getCacheKeys() in HEAD (blocks) do not follow your model. […] it is the invoker of ->getCacheKeys() whose responsible for adding the rest of CID when wanting to cache the item in its own entry.

Hah, great point! Let's consider this a non-problem then. Great insight, thanks!

No. Access checkers have no business validating request attributes. I think the ones we have that still do that (in e.g., quickedit) have @todos to fix that.

I like the sound of that. I'd like to call out that AFAICT none or almost none of those that do this incorrectly then today have @todos, and it's not limited to relatively unimportant access checkers like the ones for Quick Edit, it's also in EntityAccessCheck, EntityCreateAccessCheck, ConfigTranslationFormAccess, ConfigTranslationOverviewAccess, ConfigTranslationManageAccess, FormModeAcessCheck, ViewModeAccessCheck, etc. There's quite a lot of them.
And even in cases where no attribute validation is happening, like in PathFieldItemList::defaultAccess() or ShortcutSetSwitchAccessCheck, there is "phased access granting": if the user has a certain role with some sort of "admin powers" permission, then the result is cacheable per role, if not, then it's going to be cacheable per role and per user. In other words: there are still going to be scenarios where you need to duplicate the logic that determines what the access check result is (ALLOW/DENY/KILL) to generate the corresponding cacheability metadata.

Meanwhile, lumping cache info into the access() method means that we'll never be able to cache access check results on their own.

I don't see why this would be true? It'd always be up to the thing calling the access checkers to cache the results, and I don't see why doing

(ACCESS, CACHEABILITY) = access_function(x, y, z)

instead of

ACCESS = access_function(x, y, z)
CACHEABILITY = cacheability_function(x, y, z)

does not result in the same possibilities, while removing duplicate logic.
If you want access checkers themselves to cache the results, then I definitely don't see the problem. Also see the above "phased access granting" remark.


Since it sounds like we're discussing implementation details rather than the concept, I'm going to continue to work on this patch today.

Wim Leers’s picture

Straight reroll. Next step: continue this work-in-progress to have a testable patch.

Wim Leers’s picture

While working on this, I encountered existing test coverage being broken, impeding the work of this patch. Opened #2313115: AccessManagerTest is broken to fix that..

Wim Leers’s picture

#2313115: AccessManagerTest is broken was committed already! :)

Just uploaded the first complete patch that should be mostly green. Did that over at #2280195-157: Ignore: patch testing issue, where I will continue to send it to testbot to make the patch green first, to avoid creating a lot of noise here.

Wim Leers’s picture

FileSize
298.61 KB

Please review the concept of this patch! Please disregard implementation details such as the specific signature of the AccessCheckResult and Cacheability objects. It can be changed. It can be improved.


The concept & its consequences

What matters above all, is that all access checkers, and in fact anything that implements AccessibleInterface, plus on top of that all the entity access-related things (the hooks and EntityAccessControllerInterface) has been converted to no longer return either an AccessInterface constant or TRUE/FALSE/NULL, but is now without exception converted to returning an AccessCheckResult object. Why an object? Because we can then associate cacheability metadata with the result.

The consequence of this is that when we check access for something, we know whether the result applies to all users of this role ("cacheable per role"), or just this single user ("cacheable per user"), per language ("cacheable per language") or perhaps applicable to all contexts ("globally cacheable"). But also whether it's dependent on something that might change ("cacheable until this entity is modified", which is a fancy way of saying "this access check result is valid until the specified cache tag (the one for this entity) is invalidated). And, of course, the combination: "cacheable per role, until the entity is modified" (e.g. all anonymous users may see this node, until it's modified, for example because it's unpublished).

This cacheability metadata is what this issue is all about. It will allow us to e.g. cache rendered menus effectively. But that's not the only area where it will be of use. I can describe other use cases, but I'll do that in another comment, to not make this unnecessarily long.


From a syntax error (#157 in the patch testing issue), to 950 fails, to 64 fails, to 22 fails to finally zero fails. (There is still a single exception in there that I have to finish debugging for, but it doesn't stand in the way of reviewing the concept. Hence I commented out the line triggering it and added a @todo that will need to be resolved before this can be committed.)

Status: Needs review » Needs work

The last submitted patch, 15: access_cacheability_metadata-2287071-13.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
298.89 KB
1.15 KB

And sadly, I didn't test whether commenting out that one line would indeed cause the test to pass. Turns out that I needed to comment two lines. Stupid! This one should be green. This is the patch I should've uploaded in #15.

dawehner’s picture

General review:

Given that this makes the DX way of writing access related functions way worse we should try to improve it. At least
adding methdos like isAllowed() would also save you to include the access interface all the time.

On top of that I am confused about the cacheability because some of the checkers depend on the request so I don't see how they could be cacheable (toolbar is one example).

This is a mix of detail/nitpicks/highlevel/random thoughts.

Change: \Drupal\Core\Routing\Access\AccessInterface now also implements the newly added CacheabilityAffectorInterface

I wonder whether we could theoretically let us just mark specific instances as CacheabilityAffectorInterface given that this adds a dependency for our routing component to our cache component. Otherwise add a CacheableAccessInterface which includes both or something else.

  1. +++ b/core/lib/Drupal/Core/Access/AccessCheckResult.php
    @@ -0,0 +1,87 @@
    +/**
    + * Value object for passing an access check result with cacheability metadata.
    + */
    +class AccessCheckResult {
    

    <3 value objects! We have to open up a beer on value objects at drupalaton

  2. +++ b/core/lib/Drupal/Core/Access/AccessCheckResult.php
    @@ -0,0 +1,87 @@
    +   * @var string
    

    Meh, can we describe that just a couple of constants are allowed here?

  3. +++ b/core/lib/Drupal/Core/Access/AccessCheckResult.php
    @@ -0,0 +1,87 @@
    +   *   Whether this access check result is cacheable or not. This is crucial
    +   *   information for the access check result, hence it must be set upon
    +   *   construction.
    

    It would be great to have some definition somewhere which explains the concept of cacheability.

  4. +++ b/core/lib/Drupal/Core/Access/AccessCheckResult.php
    @@ -0,0 +1,87 @@
    +    // Calculate the combined result, starting with the merged cacheability
    +    // metadata.
    +    $access = new AccessCheckResult(TRUE);
    +    $merge_cacheability = function(Cacheability $carry, AccessCheckResult $current) {
    +      return $carry->merge($current->cacheability);
    +    };
    +    $access->cacheability = array_reduce($access_check_results, $merge_cacheability, $access->cacheability);
    +
    +    $get_access = function(AccessCheckResult $access_check_result) {
    +      return $access_check_result->value;
    +    };
    +    $access_check_values = array_map($get_access, $access_check_results);
    

    Nothing against the usage of map and reduce but can we try to explain a bit better what the code does at the end?

  5. +++ b/core/lib/Drupal/Core/Access/AccessCheckResult.php
    @@ -0,0 +1,87 @@
    +
    +    // 'ANY' merging behavior: at least one checker should allow access.
    +    if ($behavior === 'ANY' && in_array(AccessInterface::ALLOW, $access_check_values, TRUE)) {
    +      $access->value = AccessInterface::ALLOW;
    +    }
    +    // 'ALL' merging behavior: every checker should allow access.
    +    else if ($behavior === 'ALL' && !in_array(AccessInterface::DENY, $access_check_values, TRUE)) {
    +      $access->value = AccessInterface::ALLOW;
    +    }
    +
    

    Does that mean we cannot longer lazy-evaluate multiple access checkers? Just like if we would have overridden the && operator in cpp.

  6. +++ b/core/lib/Drupal/Core/Access/AccessCheckResult.php
    @@ -0,0 +1,87 @@
    +  public static function any(array $access_check_results) {
    ...
    +  public static function all(array $access_check_results) {
    

    This is btw. NOT a value object ... as it contains quite some logic. Maybe the logic should live somewhere else.

  7. +++ b/core/lib/Drupal/Core/Access/AccessManager.php
    @@ -41,7 +41,7 @@ class AccessManager implements ContainerAwareInterface, AccessManagerInterface {
    @@ -194,10 +194,18 @@ public function checkNamedRoute($route_name, array $parameters = array(), Accoun
    
    @@ -194,10 +194,18 @@ public function checkNamedRoute($route_name, array $parameters = array(), Accoun
    +      // Cacheable until extensions change.
    +      $access->cacheability->setTags(array('extension' => TRUE));
    

    Well, routes are not only provided by extensions but also my actions in the UI: create a new page manager display/view. So is this really cacheable that way?

  8. +++ b/core/lib/Drupal/Core/Access/AccessManager.php
    @@ -228,30 +236,34 @@ public function check(Route $route, Request $request, AccountInterface $account)
       protected function checkAll(array $checks, Route $route, Request $request, AccountInterface $account) {
    ...
    +      // Stop as soon as the first DENY or KILL is encountered.
    +      if ($result->value !== AccessInterface::ALLOW) {
             break;
    

    Okay we seem to still be able to lazy-evaluate stuff.

  9. +++ b/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
    @@ -45,15 +45,19 @@ function __construct(CsrfTokenGenerator $csrf_token) {
    +    $access = new AccessCheckResult(FALSE);
    
    @@ -61,12 +65,14 @@ public function access(Route $route, Request $request) {
    +      $access->value = static::ALLOW;
    

    It is a little bit odd to have one bit set via the constructor and one on the property, but yeah this is not horrible just odd.

  10. +++ b/core/lib/Drupal/Core/Cache/Cacheability.php
    @@ -0,0 +1,138 @@
    +    // Use the lowest max-age.
    +    if ($this->maxAge === Cache::PERMANENT) {
    +      // The other max-age is either lower or equal.
    +      $this->setMaxAge($other->maxAge);
    +    }
    +    else {
    +      $this->setMaxAge(min($this->maxAge, $other->maxAge));
    +    }
    +    return $this;
    

    I am confused. Let's assume $this has max age 10 and $other has permanent. Given that permanent is -1 it will be set. I would have expected 10 to be lower

  11. +++ b/core/lib/Drupal/Core/Entity/EntityAccessController.php
    @@ -89,23 +93,37 @@ public function access(EntityInterface $entity, $operation, $langcode = Language
    +    // Calculate the combined result, starting with the merged cacheability
    +    // metadata.
    +    $result = new AccessCheckResult(TRUE);
    +    $merge_cacheability = function(Cacheability $carry, AccessCheckResult $current) {
    +      return $carry->merge($current->cacheability);
    +    };
    +    $result->cacheability = array_reduce($access, $merge_cacheability, $result->cacheability);
    +
    +    $get_access = function(AccessCheckResult $access_check_result) {
    +      return $access_check_result->value;
    +    };
    +    $access_check_values = array_map($get_access, $access);
    

    Isn't that the exact same code as in the merge method of AccessCheckResult?

  12. +++ b/core/lib/Drupal/Core/Entity/EntityForm.php
    @@ -229,7 +230,7 @@ protected function actions(array $form, FormStateInterface $form_state) {
    +        '#access' => $this->entity->access('delete')->value === AccessInterface::ALLOW,
    

    I wonder whether we could add methods like isAllowed, isDenied and isKilled on the AccessCheckResult object. There are a couple of places where we do such an comparison

  13. +++ b/core/lib/Drupal/Core/Field/FieldItemList.php
    @@ -197,7 +199,9 @@ public function access($operation = 'view', AccountInterface $account = NULL) {
    -    return TRUE;
    +    $access = new AccessCheckResult(TRUE);
    +    $access->value = AccessInterface::ALLOW;
    +    return $access;
    

    It is quite sad how much worse the DX is. We worked as hard to make access as simple as possible, using service tags instaed of appliesStatic() and what not.

  14. +++ b/core/lib/Drupal/Core/Menu/LocalTaskManager.php
    index 125bbf0..9778afa 100644
    --- a/core/lib/Drupal/Core/Menu/MenuLinkBase.php
    
    --- a/core/lib/Drupal/Core/Menu/MenuLinkBase.php
    +++ b/core/lib/Drupal/Core/Menu/MenuLinkBase.php
    
    +++ b/core/lib/Drupal/Core/Menu/MenuLinkBase.php
    +++ b/core/lib/Drupal/Core/Menu/MenuLinkBase.php
    @@ -9,6 +9,8 @@
    

    I don't see changes to the interface ... This also not sounds like a proper is*() method anymore, given that it returns a value object.

  15. +++ b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php
    @@ -94,7 +96,9 @@ public function getDescription() {
       public function isResettable() {
         // The link can be reset if it has an override.
    -    return (bool) $this->staticOverride->loadOverride($this->getPluginId());
    +    $access = new AccessCheckResult(FALSE);
    +    $access->value = ((bool) $this->staticOverride->loadOverride($this->getPluginId())) ? AccessInterface::ALLOW : AccessInterface::DENY;
    +    return $access;
       }
    

    Why is that not cacheable? Nothing changes beside the Plugin ID. In general I really wonder whether it is worth to cache that admin listing, given that it contains potentially nodes/entities which aren't cacheables again. I guess your plan is to cache /admin menu?

  16. +++ b/core/modules/block/src/BlockBase.php
    @@ -156,10 +158,17 @@ public function access(AccountInterface $account) {
    +    // This should not be hardcoded to an uncacheable access check result, but
    +    // in order to fix that, we need condition plugins to return cache contexts,
    +    // otherwise it will be impossible to determine by which cache contexts the
    +    // result should be varied.
    

    Can we add a todo + followup?

  17. +++ b/core/modules/book/src/Access/BookNodeIsRemovableAccessCheck.php
    @@ -39,11 +40,15 @@ public function __construct(BookManagerInterface $book_manager) {
    +    // Cacheable until the book node is modified.
    +    $access->cacheability->setTags($node->getCacheTag());
    +    $access->value = $this->bookManager->checkNodeIsRemovable($node) ? static::ALLOW : static::DENY;
    

    This seems to assume book manager internals ... but I guess this is fine.

  18. +++ b/core/modules/comment/src/CommentAccessController.php
    @@ -22,33 +24,67 @@ class CommentAccessController extends EntityAccessController {
    +          $commented_entity_access = $entity->getCommentedEntity()->access($operation, $account);
    +          // We want access the commented entity to determine access to the
    +          // comment, so we don't specify an opinion yet (we use DENY), but we
    +          // want our cacheability metadata to be reflected, so we merge our
    +          // access check result with that of the commented entity using the
    +          // AccessCheckResult::any() method.
    

    Can't we just get the commented_entity_access and set the cacheablity tags on there? This merging feels more complex tbh.

  19. +++ b/core/modules/contact/src/CategoryAccessController.php
    @@ -22,14 +24,22 @@ class CategoryAccessController extends EntityAccessController {
           // Do not allow access personal category via site-wide route.
    -      return $account->hasPermission('access site-wide contact form') && $entity->id() !== 'personal';
    +      $access->value = ($account->hasPermission('access site-wide contact form') && $entity->id() !== 'personal') ? AccessInterface::ALLOW : AccessInterface::DENY;
    ...
    +      $access->value = ($account->hasPermission('administer contact forms') && $entity->id() !== 'personal') ? AccessInterface::ALLOW : AccessInterface::DENY;
    +      // Cacheable per role.
    +      $access->cacheability->addContexts(array('cache_context.user.roles'));
    

    I try to understand where the $entity->id() condition is taken into account for cacheability.

  20. +++ b/core/modules/file/src/FileAccessController.php
    @@ -21,21 +23,26 @@ class FileAccessController extends EntityAccessController {
    +    $no_access = new AccessCheckResult(TRUE);
    +    $no_access->value = AccessInterface::DENY;
    ...
    +
    +    return $no_access;
    

    Maybe just create this object right before returning it. It is not used somewhere else.

  21. +++ b/core/modules/menu_ui/src/Form/MenuLinkResetForm.php
    @@ -115,12 +116,13 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
    +    $access->value = $menu_link_plugin->isResettable() ? AccessInterface::ALLOW : AccessInterface::DENY;
    

    didn't you changed that to a value object? This will always be true.

  22. +++ b/core/modules/node/src/NodeAccessController.php
    @@ -59,15 +61,24 @@ public static function createInstance(ContainerInterface $container, EntityTypeI
    +    $parent_access = parent::access($entity, $operation, $langcode, $account);
    +    return AccessCheckResult::any(array($access, $parent_access));
    

    Same as before, why do we not set the cacheablity directly here. this any-ness is confusing.

  23. +++ b/core/modules/node/src/NodeGrantDatabaseStorage.php
    @@ -54,10 +56,12 @@ public function __construct(Connection $database, ModuleHandlerInterface $module
    +    $access = new AccessCheckResult(TRUE);
    ...
         if (!$this->moduleHandler->getImplementations('node_grants') || !$node->id()) {
    -      return;
    

    Just move the $access into the if()

  24. +++ b/core/modules/node/src/NodeGrantDatabaseStorage.php
    @@ -86,7 +90,14 @@ public function access(NodeInterface $node, $operation, $langcode, AccountInterf
    +    // Node grants currently don't have any cacheability metadata. Hopefully, we
    +    // can add that in the future, which would allow this access check result to
    +    // be cacheable. For now, this must remain marked as uncacheable, even when
    +    // it is theoretically cacheable, because we don't have the necessary meta-
    +    // data to know it for a fact.
    +    $access = new AccessCheckResult(FALSE);
    

    TODO + followup.

  25. +++ b/core/modules/quickedit/tests/src/Access/EditEntityFieldAccessCheckTest.php
    @@ -60,34 +62,49 @@ protected function setUp() {
    +      ->will($this->returnValue($entity_access));
    ...
    +      ->will($this->returnValue($no_entity_access));
    

    Let's use $this->willReturn instead.

  26. +++ b/core/modules/shortcut/shortcut.module
    @@ -104,35 +114,49 @@ function shortcut_set_edit_access(ShortcutSetInterface $shortcut_set = NULL) {
    +  else if ($user->id() == $account->id()) {
    

    ha, it is elseif (I don't know why though)

  27. +++ b/core/modules/system/entity.api.php
    @@ -524,7 +525,11 @@
    +  // Cacheable per role.
    +  $access->cacheability->setContexts(array('cache_context.user.roles'));
    +  $access->value = NULL;
    
    @@ -550,7 +554,11 @@ function hook_entity_access(\Drupal\Core\Entity\EntityInterface $entity, $operat
    +  // Cacheable per role.
    +  $access->cacheability->setContexts(array('cache_context.user.roles'));
    +  $access->value = NULL;
    

    NULL is cacheable per role? maybe we should use a hasPermission() as example

  28. +++ b/core/modules/toolbar/src/Controller/ToolbarController.php
    @@ -44,7 +45,11 @@ public function subtreesJsonp() {
    +    $access = new AccessCheckResult(TRUE);
    +    // Cacheable per role.
    +    $access->cacheability->setContexts(array('cache_context.user.roles'));
    +    $access->value = ($this->currentUser()->hasPermission('access toolbar') && ($hash == _toolbar_get_subtrees_hash($langcode))) ? AccessInterface::ALLOW : AccessInterface::DENY;
    

    Just a general question: You add a new entry to a menu, which will change the hash. How will this be reflected here? The url might change, so the access might be granted even the actual hash could be already different.

  29. +++ b/core/modules/update/src/Access/UpdateManagerAccessCheck.php
    @@ -39,7 +40,11 @@ public function __construct(Settings $settings) {
    +    // Uncacheable because the access check result depends on a Settings
    +    // key-value pair, and can therefore change at any time.
    
    +++ b/core/modules/user/src/Access/RegisterAccessCheck.php
    @@ -24,10 +25,15 @@ class RegisterAccessCheck implements AccessInterface {
    +    $access = new AccessCheckResult(TRUE);
    +    $access->value = ($request->attributes->get('_menu_admin') || $account->isAnonymous()) && (\Drupal::config('user.settings')->get('register') != USER_REGISTER_ADMINISTRATORS_ONLY) ? static::ALLOW : static::DENY;
    +    // Cacheable per role.
    

    So what is different between settings and config?

  30. +++ b/core/modules/user/src/UserAccessController.php
    @@ -20,44 +22,60 @@ class UserAccessController extends EntityAccessController {
    +    // Administrators can view/update/delete  all user profiles.
    

    sometimes I see two spaces.

pounard’s picture

So you're adding cacheability information (throught the Cacheability value object) onto the AccessCheckResult object, but at which point are you actually using the Cacheability object to cache anything ? Will that be in specific usages such as in menu links caching ?

While this seems to be thoroughly thought, my first reaction while reading this patch was it seems that there is so many value object indirection here. That thought put aside the only thing that bothers me is the fact that AccessCheckResult knows about Cacheability value object; Eventually it couples the access check system to the cache system somehow, at the interface level of the AccessManagerInterface and that's really inconvenient and probably unnecessary.

The cacheability is supposed to be used only the menu links to be cacheable, and only for the ones that would need later display (tabs, contextual or action links, and menu blocks). Isn't there a way to keep the cacheability information at this level ? It would be great to see this not bleeding over the raw access system.

effulgentsia’s picture

What matters above all, is that all access checkers, and in fact anything that implements AccessibleInterface, plus on top of that all the entity access-related things (the hooks and EntityAccessControllerInterface) has been converted to no longer return either an AccessInterface constant or TRUE/FALSE/NULL, but is now without exception converted to returning an AccessCheckResult object. Why an object? Because we can then associate cacheability metadata with the result.

I'm currently torn on this. I think I see the value of it (as opposed to separating in the way I suggested in #8), but I think the top of #18 and #19 raise some good concerns about the DX + complexity of intertwining the result value and its cacheability info. I need to think about this some more before giving substantive feedback. But I'm very happy to see so much (i.e., all of the) conversion done already, since looking through all those use cases will inform the benefit / weight analysis + options for alternative formulations.

catch’s picture

I'd like to see stub usage of this even if it's pseudo code, because I'm not sure how it resolves the following;

The point at which you need to know cacheability metadata is when determining a cache ID. At that point no access checkers can be run, because you don't know what they will be yet.

#2099137: Entity/field access and node grants not taken into account with core cache contexts is open for this and the overall idea so far for entity render caching was just to allow access modules to interact at the point the cache ID is created, that doesn't at all appear to be the case here.

Wim Leers’s picture

#18:

this makes the DX way of writing access related functions way worse

I disagree that this makes the DX "way worse". The current DX is bad, because sometimes you have to return one of the AccessInterface constants, and sometimes it's TRUE/FALSE and yet other times it's TRUE/FALSE/NULL. This makes it consistent. That being said…

we should try to improve it

This I of course agree with :) It's also why I repeated *several* times that this should receive a concept review, not an implementation review. It's easy to make the DX of the implementation better.

RE: CacheabillityAffectorInterface: that's gone now, I just haven't updated the issue summary yet.

Since it's too early to do an implementation review, and most of the review is in fact just that, I'm only responding to concept-related parts of the review for now.

  • 1.
    <3 value objects! We have to open up a beer on value objects at drupalaton

    Haha :D :D

  • 5. No, it doesn't mean that. Just look at AccessManager::check(All|Any)() and you'll see that this optimization is still in place. The AccessCheckResult class merely is the new home for this logic, because it's also being used elsewhere.
  • 6. Well, they're static methods. But I see your point. This could happily live elsewhere though, so there's no problem in changing that.
  • 7. Perhaps it isn't cacheable then, indeed. However, this patch definitely helps us discover where we still have gaps in dependency information. Because to have cacheability, you need cache invalidation metadata, and for that, you need dependency metadata. Clearly, we're missing that for routes.
  • 9. I agree, and we can change that. For now, the new AccessCheckResult(TRUE|FALSE) pattern is quite handy, because it makes it very easy to grep the code base for access check results that are not cacheable. Plus, it forces the developer to think about the cacheability of his/her access checks (this was the main reason, the grepping was a consequence that I found to be quite handy).
  • 11. I think you're right; this existed first. It should be replaced with a call to AccessCheckResult::merge().
  • 12. Absolutely! I wanted to do this already, but at the same time I didn't feel like spending the time doing that because it's irrelevant for getting feedback on the concept.
  • 13. See earlier. It forces developers to think about more things for sure. But that also has a beneficial side-effect: because we force them to provide cacheability metadata, they're forced to think about their dependencies, and to keep those to a minimum. Hopefully that will lead to fewer crazy access checkers. Finally: I wish I didn't have to roll this patch at all, but sadly when the access API was designed, cacheability metadata was not taken into account, which is why we're faced with this problem only now… (This is not meant as a stab, just an observation. Given where Drupal 8 is coming from — i.e. Drupal 7 — it makes sense that this wasn't considered.)
  • 18. Yes, that's possible also. In some other locations, the merging makes more sense. I chose to use one pattern always, rather than two different patterns that are applicable in different situations.

#19:

but at which point are you actually using the Cacheability object to cache anything ? Will that be in specific usages such as in menu links caching ?

Correct. This will be used in #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees.

Isn't there a way to keep the cacheability information at this level ? It would be great to see this not bleeding over the raw access system.

I think that by "this level", you mean at the menu links level rather than the access checker level?
Assuming that I understood/interpreted that correctly, the answer is: because menu links themselves don't know which access checkers will apply to them. A menu link is essentially a reference to a route (or external URL), plus a title/label, plus tree metadata, plus less important metadata. From a performance POV, it'd be awesome if we could say that a given menu link was visible if the user had a certain permission. But that's not how it is designed currently. We currently want menu links to "automagically" adapt to the current user, to prevent showing any links that may result in a 403 (which is a valid reason and worthy goal, I think).
Consequently, given a menu link, we look at the route and see if the current user has access to it. Which means running all the access checkers. Which means the cacheability metadata must live there. Since the accessibility of a route is also determined by access checkers, it's only logical to put the burden of providing the cacheability metadata on the access checkers as well.
Finally, we should be able to use the cacheability metadata for other use cases as well.


#20: I understand. I think you must keep one important fact in mind: the logic that determines the access check result (ALLOW/DENY/KILL) is also the logic to determine the cacheability metadata. That may sound rather abstract, so let me give you one clear example. From UserAccessController::checkAccess():

  protected function checkAccess(EntityInterface $entity, $operation, $langcode, AccountInterface $account) {
    $access = new AccessCheckResult(TRUE);

    // The anonymous user's profile can neither be viewed, updated nor deleted.
    if ($entity->id() == 0) {
      $access->value = AccessInterface::KILL;
      return $access;
    }

    // Administrators can view/update/delete  all user profiles.
    if ($account->hasPermission('administer users')) {
      // Cacheable per role.
      $access->cacheability->setContexts(array('cache_context.user.roles'));
      $access->value = AccessInterface::ALLOW;
      return $access;
    }

    switch ($operation) {
      case 'view':
        // Users can view own profiles at all times.
        if ($account->id() == $entity->id()) {
          // Cacheable per user.
          $access->cacheability->setContexts(array('cache_context.user'));
          $access->value = AccessInterface::ALLOW;
        }
        // Only allow view access if the account is active.
        elseif ($account->hasPermission('access user profiles')) {
          // Cacheable until user is modified.
          $access->cacheability->setTags($entity->getCacheTag());
          // Cacheable per role.
          $access->cacheability->setContexts(array('cache_context.user.roles'));
          $access->value = $entity->status->value ? AccessInterface::ALLOW : AccessInterface::DENY;
        }
        else {
          $access->value = AccessInterface::DENY;
        }
        return $access;
        break;

As you can see, there are several "phases" or "levels of access" contained within this access checker:

  1. First phase: if we're checking access to the anonymous user's profile, the answer is always KILL. This is a globally cacheable result, because the answer is the same for everyone, always. If this is not the case, go to the next step.
  2. Second phase: if the current request is from a user that has the all-encompassing "administer users" permission, then the answer is always ALLOW. Since it depends on a permission, it's cacheable per role. If this is not the case, go to the next step.
  3. Third phase: if the current request is from the owner of the profile, then the answer is always ALLOW. Since this depends just on the user that's looking at the profile, this result is cacheable per user.
  4. Fourth phase: if the current request is from a user that mayh view user profiles, then the answer is ALLOW if the user is not blocked, and DENY if blocked. (DENY, not KILL, so that some contrib module may add a "access blocked user profiles" permission.) Since this depends on a permission and on the status of the entity, it's cacheable per role, but only until the profile's user entity is modified (because that might block or unblock that user).
  5. Fifth phase: if none of the above is applicable, then we default/fall back to DENY, which means as much as "no opinion".

I think it's intuitively clear that these different phases or levels come with different cacheability associated with them.

The only way that I can think of to simplify this, is by getting rid of the phases. This would imply that every access checker can only perform a single if-test, i.e. something like $access->value = ($condition) ? ALLOW : DENY. This if-test would then only have one possible piece of cacheability metadata. This would allow the DX to become simpler, at the cost of having more access checkers. But having written that, I just realized one would have to always use "OR" (the "any" access mode) to combine the different access checkers… whereas we almost always use "AND". So that'd be problematic too.


#21: Hah :) I was expecting this question. Great question!

The answer is both simple and complex.

The simple version is: we don't need access cacheability metadata to determine the cache ID for the "menu links" use case. But you're right that this does not solve #2099137: Entity/field access and node grants not taken into account with core cache contexts.

The complex version is: #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees allows you to set which cache contexts it should cache by (i.e. you can configure the Vary header). By default, it caches per role (Vary: user-role). Any menu link that depends on anything else (let's say menu link A is cacheable per role and country) will be rendered into a render cache placeholder. If that didn't make sense yet, here is a longer version.
Because any menu link may depend on any number of access checkers, and all those access checkers' results may have any cacheability that you can think of, and that cacheability may differ depending on the request (as explained in the above example: the same access checker can result a result that's globally cacheable, cacheable per role, cacheable per user, cacheable per role until an entity is modified, and so on), it is by definition impossible to let access checkers affect the cache ID. Unless of course we run all these access checkers on each request, but that will in some (or maybe many) cases defeat the purpose of caching in the first place.
Therefore, it is impossible to automatically determine a cache ID that takes into account the contexts by which we should cache in order to cache a chunk of HTML that we can return immediately. Since that is impossible, the next best thing is to configure the cache contexts that become part of the cache ID for the rendered menu. We default to "per role". We can then effectively render cache all those menu links whose access check result's cacheability metadata only contains the "per role" cache context. We can also still render cache them if they have any cache tags ("cache until user X is modified"). Any menu links whose access check result depends not only on role, but also on e.g. language will be rendered into a render cache placeholder. This means that the thing we store in the cache is "blob of HTML with placeholders plus metadata to render those placeholders". This allows us to comply with the default of "per role".
This will be sufficient for most sites. Especially now that this patch will effectively make most entity access checks cacheable per role.

However… sites for which this not sufficient, i.e. sites that also have many menu links that are not cacheable per role, but "per role and country and language" (or whatever else), will be able to optimize this! They can change the default of "per role" to "per role and country and language", and voila, fewer render cache placeholders will be generated, and more cache items will be created (because there will be more combinations).

So, there you have it. It's essentially an explanation of what #1805054 will provide, once (something like) this patch lands.

(While working on this, I also thought for some time that this would help solve #2099137: Entity/field access and node grants not taken into account with core cache contexts. But it won't. Because we allow access checkers to have any logic. Hence the access check results may also cache by any context (role, user, language, etc.). And it will always be possible to create uncacheable access checkers. If we'd automatically render cache entities by the complete range of possible cache contexts, we'd end up render caching each entity for every user, for every group, for every language, and so on. It wouldn't work. We could choose to go for a similar solution to menu render caching (create render cache placeholders for those fields whose access check results depend on a superset of the "default" contexts to cache by, then render those on the fly), or we could go for the "hook to alter the cache ID" approach. But the "hook to alter the cache ID" approach is not universally applicable, as I just explained: it'd end up meaning "cache per *everything*".)

catch’s picture

We can then effectively render cache all those menu links whose access check result's cacheability metadata only contains the "per role" cache context. We can also still render cache them if they have any cache tags ("cache until user X is modified"). Any menu links whose access check result depends not only on role, but also on e.g. language will be rendered into a render cache placeholder.

How do you find this out without running the access checkers?

Wim Leers’s picture

#23: You don't; you do run access checkers for that. But that's not any different from today? When rendering menu links today, you run their access checkers, to determine whether they're visible or not.
What this patch plus #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees give us on top of that, is knowing whether the result we get back is cacheable per role/user/….
Combined with the menu rendering logic's awareness of what contexts the rendered menu will be cached by ("per role" by default), we can then choose to only render those menu links that whose access check result is globally cacheable, or cacheable per role. If a menu link's access check result is "per user", then we render it into a render cache placeholder.

catch’s picture

Let's take your example:

As you can see, there are several "phases" or "levels of access" contained within this access checker:

First phase: if we're checking access to the anonymous user's profile, the answer is always KILL. This is a globally cacheable result, because the answer is the same for everyone, always. If this is not the case, go to the next step.
Second phase: if the current request is from a user that has the all-encompassing "administer users" permission, then the answer is always ALLOW. Since it depends on a permission, it's cacheable per role. If this is not the case, go to the next step.
Third phase: if the current request is from the owner of the profile, then the answer is always ALLOW. Since this depends just on the user that's looking at the profile, this result is cacheable per user.
Fourth phase: if the current request is from a user that mayh view user profiles, then the answer is ALLOW if the user is not blocked, and DENY if blocked. (DENY, not KILL, so that some contrib module may add a "access blocked user profiles" permission.) Since this depends on a permission and on the status of the entity, it's cacheable per role, but only until the profile's user entity is modified (because that might block or unblock that user).
Fifth phase: if none of the above is applicable, then we default/fall back to DENY, which means as much as "no opinion".

Let me see if I've got this right:

Empty cache.

User A visits with 'administer users', the menu link is cached as HTML because it's treated as per-role. The cid of the render-cached menu includes the role hash so that's fine.

User B visits without that permission, it gets to phase three and returns per-user, because it's per-user, we stick it in placeholder instead.

This means that for some roles the item is a placeholder, and for others it is directly cached in the HTML. Whether it's cached in the HTML or put into a placeholder depends on whether the granularity the access checker returns matches the configuration of cache granularity. Since the overall granularity is known up-front, it's fine to compare this and treat individual links differently.

If that's right, I have another question, but I'll make sure we're on the same page so far before continuing :)

pounard’s picture

Just as a note, I'm quite convinced the cache metadata should not bleed and hard-couple the access check system to it. There are probably a lot of other layers in there where we can act upon to add the caching metadata. For example I'd say that menu links could use an additional interface over the controlers with an simple getCacheabilityInformation() (stupid name) method in order to build the menu links cache. Ideally I think the cacheability here solves a very specific problem and should live in the business layer where it solves it.

Wim Leers’s picture

#25: That's completely correct!

Wim Leers’s picture

FileSize
298.9 KB

Chasing HEAD. The context of two hunks had changed. Hence no interdiff.

catch’s picture

OK so the next question would be this:

The number of access checks where there are multiple potential cacheabilities is fairly low (although entity access accounts for a lot in practice).

Entity access checks tend to only return early for users with administrative permissions. While having granularity as part of the return value allows for optimizing the case where an access check returns early for some users, I'm not sure there's enough of a trade-off there to justify the complexity (and where there is, it could probably be solved by splitting some of these up anyway). Non-administrative users will generally end up with a placeholder.

So that leaves me wondering why we can't (similar to pounard's suggestion) have a method on access checks that returns access check granularity - that could also be optional and default to the highest granularity. In core the permission access check would be per-role and any router item with just that can be cached per role.

This would massively simplify what access checkers have to do (just return the most pessimistic granularity they need from a method, or do nothing to get the most pessimistic granularity possible).

Additionally I think it'll be faster on cache misses:

In the current approach, if an access check has a granularity of worse than per-role, we'll run it once to determine the granularity. Then the placeholder gets added to the menu. Then #post_render_cache is going to run the access check again to determine if the link is actually renderable or not. So all the worst access checks get run twice on that request.

If we have just a method, then we only need to run that method when generating the HTML string since we can get granularity without running the check, then the actual access checks only get run in #post_render_cache.

That also means that the render-cached portion of the menu will get into the cache quicker, since potentially expensive access checks don't need to be run before the HTML can be set - would help slightly in a cache stampede situation.

catch’s picture

Also even if it's in a separate method, it'd potentially be feasible to do something like return per role if the current user is an admin else something else. Can't use context from route parameters of course.

moshe weitzman’s picture

At a conceptual level, this patch makes a lot of sense to me. Access checks have dependencies (i.e. contexts) and thus need to affect the granularity of caches. Having them return those contexts is a step forward.

effulgentsia’s picture

What matters above all, is that all access checkers, and in fact anything that implements AccessibleInterface, plus on top of that all the entity access-related things (the hooks and EntityAccessControllerInterface) has been converted to no longer return either an AccessInterface constant or TRUE/FALSE/NULL, but is now without exception converted to returning an AccessCheckResult object. Why an object? Because we can then associate cacheability metadata with the result.

Ok, thinking about it more, I agree with that much. Meaning, I agree with changing AccessibleInterface::access() and *AccessCheck::access() methods to return an object instead of a string, in order to allow (but not require, see the end of this comment) said object to be capable of returning cacheability metadata.

First, I'll clarify why I agree with that by responding to catch's question:

So that leaves me wondering why we can't (similar to pounard's suggestion) have a method on access checks that returns access check granularity

While a method on access check services might be sufficient in some cases, it doesn't cover some of the most important ones. Take for example, a menu with 100 links in it, all to nodes, where 1 of the nodes is unpublished. Assuming the site isn't implementing per-user node grants, then 99 of those access check results can be cached per-role, and only 1 (the unpublished one) needs to be per-user (because of the "view own unpublished content" permission). So the goal is to be able to render-cache the entire menu per-role, and have a #post_render callback for just the one link to the unpublished node. That way, on a (per-role) cache hit, we don't even need to load the 99 published nodes, let alone invoke ->access() on them. Neither the EntityAccessCheck service (which is a single object for all entities, not just nodes) nor the NodeAccessController (which is a single object for all nodes) can be the ones to implement a getCacheContexts() method that solves this use case of the cache context being different per node. If we were to settle for the most pessimistic answer (per-user), then we lose out on the benefit of being able to cache much more effectively per role for the vast majority of instances.

Secondly, in response to pounard:

The cacheability is supposed to be used only the menu links to be cacheable, and only for the ones that would need later display (tabs, contextual or action links, and menu blocks). Isn't there a way to keep the cacheability information at this level ?

We already have a concept of routes having access checkers. I don't see it as being desirable in any way to invent a separate concept of link access checkers. I think any use case where you want link access to be different than route access is sketchy (we have one in core, MyAccountMenuLink::isHidden(), and I think it's sketchy, and it has a @todo about that), and needing to maintain two separate access check levels that should always be consistent with each other seems fraught with problems.

But, where I agree with pounard (at least if I'm understanding his concern correctly) is:

cache metadata should not bleed and hard-couple the access check system to it

Yes, I see the hard-coupling of AccessibleInterface::access() to have an @return of AccessCheckResult to be problematic in two ways:

  1. @params and @returns should be interfaces, not implementations.
  2. If we change it to an interface, then the interface shouldn't be required to return cacheability information. Instead, we can let specific implementations decide whether they want the objects they return to also implement CacheableInterface (or similar) or not. Callers can then check whether the returned object is an instance of that interface, and if not, then treat it as an uncacheable result. In practice, we might choose to make all of core's access checkers return cacheable results, but we don't need to hard-couple that dependency into AccessibleInterface. I think this is a similar recommendation that @dawehner made in #18 above his itemized list.

@catch, @pounard, @dawehner, @Wim: thoughts on the above?

dawehner’s picture

We already have a concept of routes having access checkers. I don't see it as being desirable in any way to invent a separate concept of link access checkers. I think any use case where you want link access to be different than route access is sketchy (we have one in core, MyAccountMenuLink::isHidden(), and I think it's sketchy, and it has a @todo about that), and needing to maintain two separate access check levels that should always be consistent with each other seems fraught with problems.

Yeah, we worked hard to use routes for access checking in various "links" related places. For now they work really fine, no reason to come up
with a new concept.

If we change it to an interface, then the interface shouldn't be required to return cacheability information. Instead, we can let specific implementations decide whether they want the objects they return to also implement CacheableInterface (or similar) or not. Callers can then check whether the returned object is an instance of that interface, and if not, then treat it as an uncacheable result. In practice, we might choose to make all of core's access checkers return cacheable results, but we don't need to hard-couple that dependency into AccessibleInterface. I think this is a similar recommendation that @dawehner made in #18 above his itemized list.

I do like this idea. This would allow us to keep a separated subsystem and share it with the world at some point. More important this keeps
us flexible as maybe someone has more ideas than just cacheable, maybe lazy-evaluation etc.

@params and @returns should be interfaces, not implementations.

Personally I don't see this per defition as a big problem though just for usage inside a limited scope of code though this isn't really the case here.

While a method on access check services might be sufficient in some cases, it doesn't cover some of the most important ones. Take for example, a menu with 100 links in it, all to nodes, where 1 of the nodes is unpublished.

If we want to optimize those kind of cases I would rather suggest to optimize on the tree level, see #2250315: Regression: Bring back node access optimization to menu trees

catch’s picture

needs to be per-user (because of the "view own unpublished content" permission).

I don't think this is one of the 'most important' cases, we just shouldn't be supporting this all the time by default. That permission should be opt-in for listings, and it already is for things like the front page view that check published as opposed to not published || author = user (unless you especially configure that via Views).

I'm not sure that's even desirable during normal operations - i.e. would you want unpublished handbook items to show up in the book navigation during normal browsing on Drupal.org? On the front page?

#32 doesn't answer the 'double check' issue on cache misses mentioned in #29, that feels like a showstopper to me unless I've got that wrong.

Wim Leers’s picture

#29/#30/#34:

The number of access checks where there are multiple potential cacheabilities is fairly low (although entity access accounts for a lot in practice).

That's also what I thought, but in practice, there are a lot of these. In fact, in most scenarios in Drupal core, entity access check results will be cacheable per role!

[talking about entity access checks] Non-administrative users will generally end up with a placeholder.

It's surprising how many entity access checks actually are cacheable per role. Hence the above statement is not actually a general truth. It's true for certain scenarios, definitely, but it's not a valid generalization. Many entities, and hence links to many entities are actually accessible with a "per-role" cacheability (but quite often with a "until the entity is modified" requirement, which means the resulting render cached menu gets the cache tags of the entities that are linked from the menu).

If we have just a method, then we only need to run that method when generating the HTML string since we can get granularity without running the check, then the actual access checks only get run in #post_render_cache.

But the method I've described also doesn't require access checkers to be run to get the cache granularity for the render cached menu to retrieve (cache hit) or build (cache miss). The cache granularity (and hence the affected cache ID) of rendered menus is configured. As explained earlier, it's in fact undesirable to let access checkers affect the cache granularity of rendered menus, because that'd imply that almost every menu would be cached per user (plus per language, etc.), which is not at all desirable, because it'd lead to extremely low cache hit ratios. That's why render cache placeholders are used to deal with links that have "more granular" (than the default configuration, which is "per role") access checkers.

I'm also generally confused by this statement. The only way we can move all invocations of access checks to #post_render_cache, is by always rendering every menu link into a post-render cache placeholder. To actually render a menu link, it's by definition necessary to run its access check, otherwise we can't know if we should display it to the current user or not. But if we render every menu link into a post-render cache placeholder, then we're not really render caching anything… except for a very "bare skeleton" that doesn't buy us much. We still have to do almost all the work on every page load in that scenario!
The cost is not in retrieving the granularity. The cost lies in running all those access checks and rendering all those menu links on every single page load. To render cache more than a "bare skeleton" of a menu, we need to run access checks when rendering a menu, so we can cache those menu links we know to be as granular or less granular than the default "menu rendering granularity" ("per role"). Hence there is no way to avoid running access checks when rendering menus, but to know whether a given link's access check result is cacheable, we need the granularity associated with the access check result… and that's all this is about.

Then #post_render_cache is going to run the access check again to determine if the link is actually renderable or not. So all the worst access checks get run twice on that request.

First of all: it's not because an access checker has worse cacheability (i.e. "per user" is objectively worse than "per role") that it also is expensive to run. It very often amounts to comparing the entity's owner ID to the current user ID, both of which are already loaded into memory anyway, so these "worst access checks" (with worse (more granular) than "per role" cacheability) usually amount to a function call in which very simple comparisons are performed.
Second: when rendering menus, the #post_render_cache does not need to run access checkers for all of those whose cache granularity is more granular than the default ("per role"). Just like you and I discussed (~two months ago now), it's okay to cache the access check results for those access checks that are not "per role" on a per-user basis. i.e. we assume it's okay that access checker results remain the same for the same user, because that already is the "worst case" scenario. See #1805054-96: Cache localized, access filtered, URL resolved, and rendered menu trees, the The 10% of the problem space (that will be 90% of the work) that is not yet solved part at the bottom.

(BTW, these questions are largely about the approach that we brainstormed together! :P Of course, it's more detailed now, since I've actually fleshed it out, so it's to be expected that you have questions. But it's fundamentally the same approach.)

#32 doesn't answer the 'double check' issue on cache misses mentioned in #29, that feels like a showstopper to me unless I've got that wrong.

I think the answers above already answer this. But to make it abundantly clear, I'll give you a point-by-point overview:

  1. Access checks don't need to run to determine the granularity of render cached menus.
  2. Granularity of render cached menus is hardcoded (but configurable!), because otherwise, render cached menus would always end up being cached for the combination of all the "worst" (most granular) access checks, which would remove most of the value of caching in the first place (simply put: every menu would be render cached for every user).
  3. Access checks need to run on cache misses for rendered menus.
  4. We assumed (based on discussions in Szeged) that it's acceptable to cache the access check results on a per-user basis. Hence we do that for those menu links that are rendered into render cache placeholders, i.e. those menu links whose access checks require a more granular granularity than "per user". Thanks to the cacheability metadata being added to access checks in this issue/patch, we'll be able to tag those cache items with the proper cache tags to ensure invalidation occurs when appropriate.
  5. Rendering a menu will then amount to a render cache hit for the menu, after which the post-render cache callbacks for rendering the render cache placeholders will run, and they will consult a single (small!) cache item that contains the access check results for all of those links in that menu for the current user. Hence no access checks will run on cache hits, ever (after that per-user small cache item has been built/cached, of course). Hence rendering a menu will effectively become 2 cache hits + N post-render cache callbacks.

The expected benefits are upwards of the 12–20% of the total page generation time improvement I saw at #1805054-97: Cache localized, access filtered, URL resolved, and rendered menu trees (which contains benchmarks and profiling), because there all entity access checks were considered uncacheable, whereas with this patch, many will be cacheable per role). Absolutely speaking, this will allow us to remove 12–20K function calls per page load (out of 80–90K calls per page load).

So: yes, on a "full" cache miss (i.e. neither the rendered-menu-with-placeholders is cached nor the per-user-cache-of-access-check-results already exists), we will run some (a minority) access checks twice. But that only happens extremely rarely. The typical case will be that both are cached. The atypical case will be that a user visits whose cache entry does not yet exist, in which case only a few access checks will run.
Compare that with the situation today, where all access checks run on every page load.

What you are asking for AFAICT, would imply caching a rendered menu for every user. That would lead to much lower cache hit ratios, much bigger caches, much more frequent cache invalidation of the most expensive thing (the rendered-menu-with-placeholders) rather than only the cheaper thing (the per-user-cache-of-access-check-results of access check results for a cached rendered-menu-with-placeholders)

All of the above is informative for this discussion, but it's actually independent, because it's about a complex use case for actually using this metadata. I understand that it's necessary to understand why this cacheability metadata is actually needed. But let's not get lost in the weeds of something that's in fact independent of this issue :)


#32:

@params and @returns should be interfaces, not implementations.

I agree with this in general. But it does not always make sense to override implementations; quite often it's unnecessary in the case of value objects. However, I can see that being useful in this case, however unlikely it may be that somebody may want to do that.
So: sure, let's fix that, let's just create an AccessCheckResultInterface, and voila, this problem is solved.

specific implementations decide whether they want the objects they return to also implement CacheableInterface (or similar) or not.

I'm fine with that.


#33:

If we want to optimize those kind of cases I would rather suggest to optimize on the tree level, see #2250315: Regression: Bring back node access optimization to menu trees

I agree we should do #2250315, and I'm helping on that issue.
But doing #2250315 is orthogonal to what's being discussed here. #2250315 will make it more efficient to render menus. That's a laudable goal. That's why we're doing it. We'll get that done. No questions about that.
What this issue is about, though, is being able to cache those rendered menus at all. Currently, it's utterly impossible, because we simply don't have enough information to know whether it's safe to cache a rendered menu. This issue/patch provides that necessary metadata to make that possible.
Just to be clear: this issue and caching of rendered menus in general is no excuse for letting menu rendering be slow. We want that to be fast. But that doesn't mean we should continue to stupidly re-render something expensive on every single page load.


P.S.: Sorry for having written so much, hopefully it contains all the needed answers.

catch’s picture

If we have just a method, then we only need to run that method when generating the HTML string since we can get granularity without running the check, then the actual access checks only get run in #post_render_cache.

By this, I mean the granularity of the access check/link, not the granularity of the entire menu.

catch’s picture

I think you've misunderstood what I was asking for, here's a rough idea of how I think it could look:

A method gets added to each access checker which returns the granularity needed for the access check (not the menu).

This granularity can be based on information in the current request (so it would allow for entity access to return per-role if the user has certain permissions), but it would not be allowed to depend on route parameters.

Based on that return, links are rendered directly in the menu (and access checked there), or put into render cache placeholders.

The only difference from the proposal is that the determination is put into a separate method rather than the return value of the access check, and that the method does not get the route parameters as context. Should still cover most cases though where the granularity requirements are different.

. It very often amounts to comparing the entity's owner ID to the current user ID, both of which are already loaded into memory anyway, so these "worst access checks" (with worse (more granular) than "per role" cacheability) usually amount to a function call in which very simple comparisons are performed.

We should be aiming for a situation where the entity is not actually loaded into memory - exactly because some of these access checks only need to check permissions, not any property of the entity. Issues like #1237636: Entity lazy multiple front loading and #638070: Router loaders causing a lot of database hits for access checks are related to this. The situation has got a bit better since #638070: Router loaders causing a lot of database hits for access checks since for example views no longer need to be instantiated to run access checks afaik.

pounard’s picture

By adding a new method instead of including it in the access result, we avoid being intrusive with code that absolutely don't care about this (for exemple base implementation will be to always return a granularity per role). And we also remove the need to have the granularity at the AccessManagerInterface level, and can be used only when necessary directly by the business code that needs it. This also would make the whole patch a lot less invasive since you won't have to modify all access checks to return something different that it already is in HEAD, and just focus on the optional granularity method on its own (the whole patch would be a lot simpler, and architecturally things would be a lot less massive).

Wim Leers’s picture

#37:

I indeed misunderstood. Thanks for the clarification, that really helps!

[…] but it would not be allowed to depend on route parameters.

That would be highly problematic, because then you wouldn't be able to indicate that e.g. a node is accessible, this applies to all users of this role, until the node is modified (e.g. because it is unpublished).

The only difference from the proposal is that the determination is put into a separate method rather than the return value of the access check, and that the method does not get the route parameters as context. Should still cover most cases though where the granularity requirements are different.

I understand what you mean now — this was the crux of what you were getting at, of course.
Now here's the fun fact: what you're asking for, is exactly what the original patch in #1 did! The caveat with that approach is (copied from the current — outdated — issue summary):

Ideally, we'd be able to also let access checks specify cache tags — the results of some access checks, like BookNodeIsRemovableAccessCheck and EntityAccessCheck, depends on entities. But because access checks aren't instantiated per unique access check (e.g. per node for which access is being checked), we cannot collect the corresponding cache tags. I think it's preferable to tackle this in a follow-up issue, because it'll require relatively invasive changes to access checkers AFAICT.

That's also exactly what I said is problematic about not letting cacheability metadata depend on the route parameters. But effulgentsia asked me in #8 to not do this in a follow-up issue, but in this issue.
The problems with returning cacheability metadata in a method instead of a value object are:

  1. unable to associate the needed cache tags for most access checks (for correct invalidation of access check results)
  2. for a method-based approach to return correct (precise) cacheability metadata, that method would have to duplicate all the logic of the access() method (mentioned in #9, point 2 at the bottom) — unless you can live with incorrect (imprecise) cacheability metadata, which is what you're proposing
  3. impossible to return correct cacheability metadata in EntityAccessCheck and other entity-related access checkers because entity access hooks don't have a way to provide the cacheability metadata associated with the "is this entity accessible" logic they contain — unless you allow value objects to be returned by the entity access hooks, but NOT by the access checks

So, your proposal is an option (and it's implemented in #1), but it is IMO flawed because it makes it impossible to associate correct cache tags with access check results.

We should be aiming for a situation where the entity is not actually loaded into memory - exactly because some of these access checks only need to check permissions, not any property of the entity.

That sounds wonderful. But I think that's 1) out of scope, because this patch does not cause regressions, 2) impossible to achieve for many access checks, because they (need to) invoke $entity->access(), which by design occurs on an already-loaded entity. Changing that API at this point is likely impossible.
You're right that it's possible for the "if own entity" type check, of course.


#38:

[…] we avoid being intrusive with code that absolutely don't care about this (for exemple base implementation will be to always return a granularity per role

If this is the concern, we can make AccessCheckResult's cacheability default to "per role". That's trivial to do.

And we also remove the need to have the granularity at the AccessManagerInterface level, and can be used only when necessary directly by the business code that needs it.

But a specific access check result (ALLOW/DENY/KILL) inherently depends on certain information, and the cacheability information describes those dependencies. That's why we need it to be tightly associated, otherwise we get incorrect/imprecise cacheability metadata, which is significantly less useful.
Furthermore, somebody might want to change the default AccessManager to perform caching of access check results.
If the cacheability metadata was being unrelated to the access check result (i.e. unrelated data was being forced into the same data structure), then I'd agree with you. But because that's not the case, I disagree.

This also would make the whole patch a lot less invasive […]

… and also a lot less useful. We'd literally be back to square 1: the patch in comment #1. Also see the background information in my reply to #37 :)

catch’s picture

That would be highly problematic, because then you wouldn't be able to indicate that e.g. a node is accessible, this applies to all users of this role, until the node is modified (e.g. because it is unpublished).

Not sure it prevents that. You won't get the actual node, but you'll know that the access check takes a node as a parameter, so it should be possible to indicate that. In terms of an implementation that adds cache tags to access check caches, that should also be possible by adding a separate method rather than baking it into return values no?

Wim Leers’s picture

[…] but you'll know that the access check takes a node as a parameter, so it should be possible to indicate that.

I interpret this as if an entity is passed to the access() method, then you should simply always associate that entity's cache tag with the access check result (ALLOW/DENY/KILL).

If that's indeed what you meant: that's better but still very problematic, because many access checks accept an entity but only use it optionally, i.e. they only use it (and hence depend on it) if several conditions don't apply (e.g. the user doesn't have some sort of "administer taxonomy terms" permission). So then we're still associating incorrect/imprecise cacheability metadata with the access check result.

effulgentsia’s picture

The only difference from the proposal is that the determination is put into a separate method rather than the return value of the access check, and that the method does not get the route parameters as context. Should still cover most cases though where the granularity requirements are different.

So we have 3 situations:

  1. Route access checkers for which neither the cache granularity nor the cache tags depend on any route parameters (e.g., PermissionAccessCheck).
  2. Route access checkers for which the cache granularity does not depend on route parameters, but the cache tags do (e.g., ThemeAccessCheck).
  3. Route access checkers for which both cache granularity and cache tags depend on route parameters (e.g., EntityAccessCheck).

To address the first case, what if we allow, but don't require, access checkers to also implement CacheableInterface? Access checkers choosing to do this would be announcing to the system that their cache information doesn't depend on route parameters, so they prefer to provide their information directly rather than via the access result object.

To address the third case, I still don't see a way to avoid making the access result object be the one to implement CacheableInterface.

To address the second case, we could choose to create some additional method on access checkers, other than the access() method, to which we pass route parameters, but I don't see the benefit of that (I suggested something like that in #8, but the more I think about that, the more I think it doesn't help either performance or DX). If we choose to solve the 3rd case by allowing the result of the access() method to potentially implement CacheableInterface, then I think that approach is equally appropriate to use in the 2nd case (where only tags depend on the route parameters), because in most cases, it's the preparation of the route parameters that's the expensive step.

#32 doesn't answer the 'double check' issue on cache misses mentioned in #29, that feels like a showstopper to me unless I've got that wrong.

In the cases where the access check itself is potentially expensive beyond the loading of the route parameters (e.g., node access that relies on node grants), the access check result object returned by the access checker can be lazy. In other words, the object implementing both AccessCheckResultInterface and CacheableInterface just means that it needs to have a getValue() method (or perhaps instead isAllowed() and isDenied() methods, but that can be bikeshed later) and getCache*() methods. An access checker where all the logic is cheap could use something like the AccessCheckResult value object implementation in Wim's patch, but something like NodeAccessController could return a custom implementation that has a fast implementation of getCache*() and a slow implementation of getValue() that would only be invoked once. @catch: would that address your 'double check' concern?

This also would make the whole patch a lot less invasive since you won't have to modify all access checks to return something different that it already is in HEAD

Just clarifying that my proposal in this comment would still be very invasive, because it would still require changing all access checkers to return an object of type AccessResultInterface rather than a string constant. Unless we want to allow either a string or object return value, but I think that makes for ugly @return documentation and ugly logic in the callers to deal with return values that can be of different primitive types.

Wim Leers’s picture

FileSize
305.51 KB

Chasing HEAD because #2154435: Rename EntityAccessController to EntityAccessControlHandler, #2307681: getEntityTypeFromStaticClass - poor performance and #2098355: Missing default access for all node fields — all of which caused a lot of changes that forced manual updating of this patch.

This patch does not yet take all the feedback into account. A patch that does is coming later today.

Wim Leers’s picture

@effulgentsia and I had a long chat about how to move this issue forward. I think the main concerns in this issue that have been raised so far are 1) DX, 2) tying the access system to the cache system, which we don't want.

This reroll should address that.

It renames AccessCheckResult to AccessResult. AccessResult implements AccessResultInterface. Everything is typehinted to AccessResultInterface. But, the default implementation of it (AccessResult) also happens to implement CacheableInterface, hence allowing for cacheability metadata. But it's no longer required. It's just strongly encouraged.

This will also allow for the pattern that effulgentsia described in #42 to perform lazy evaluation of access checks: e.g. for node grants, we could choose to use a different implementation of AccessResultInterface that only executes the DB query if its value is actually requested. I will post that tomorrow, then you'll also have a clear interdiff showing what that'd look like.

Overall, this reroll significantly enhances the DX compared to all previous patches. In most cases, nothing really changes, it's just as legible as before, just a bit richer (because cacheability metadata is added).

Looking forward to your reviews!

Wim Leers’s picture

Wim Leers’s picture

FileSize
332.39 KB

Just to make sure that this doesn't cause a performance regression, I did some profiling.

Setup:

  1. node 1, with a title, body and two taxonomy terms
  2. node 2, with a title and body
  3. render caching disabled
  4. aggregator module enabled, which adds three more links to the Tools menu
  5. authenticated (admin user), so also a toolbar being rendered (which has a lot of access checks)
  6. PHP 5.5.11, OpCache, XHProf
HEAD patch Diff Diff%
Number of Function Calls 115,200 116,369 1,169 1.0%
Incl. Wall Time (microsec) 374,888 378,844 3,956 1.1%
Incl. MemUse (bytes) 24,038,264 24,139,184 100,920 0.4%
Incl. PeakMemUse (bytes) 24,185,176 24,286,136 100,960 0.4%

As you can see, essentially status quo:

  1. Slightly more function calls (which is only logical, given that we replace returning booleans/strings with value objects that need function calls to be built)).
  2. Slightly slower: 375 vs 379 ms, but despite the same queries (PDOStatement::execute()) and same files being loaded (Composer\Autoload\ClassLoader::findFileWithExtension), it takes 2 ms more in the latter — this is the I/O variability skewing the results. Which means the difference is really only 2 ms, and that's without looking at other I/O. Therefore the difference is around the 0.5% mark in XHProf, which is less than its margin of error. Similarly small differences when benchmarking with ab.
  3. Slightly more memory: by design, because the value object stores cacheability metadata.

As said earlier, the original patch in #2273277: Figure out a solution for the problematic interaction between the render system and the theme system when using #pre_render that worked saw 12–20% performance improvements. Since this patch now provides better cacheability metadata (entity access checks also have cacheability metadata, and almost all node links will be cacheable per role), I expect that number to be even better once I reroll it on top of this patch!

tim.plunkett’s picture

Status: Needs review » Needs work

Overall I like the flexibility and power this gives us. The DX hit isn't too bad.

The patch doesn't apply cleanly right now.

  1. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,372 @@
    +  public $contexts;
    ...
    +  public $tags;
    ...
    +  public $maxAge;
    

    Why are these public?

  2. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,372 @@
    +  public function allow() {
    ...
    +  public function forbid() {
    ...
    +  public function reset() {
    ...
    +  public function allowIf($condition) {
    ...
    +  public function forbidIf($condition) {
    ...
    +  public function setCacheable($is_cacheable) {
    ...
    +  public function addCacheContexts(array $contexts) {
    ...
    +  public function resetCacheContexts() {
    ...
    +  public function addCacheTags(array $tags) {
    ...
    +  public function resetCacheTags() {
    ...
    +  public function setCacheMaxAge($max_age) {
    ...
    +  public function cachePerRole() {
    ...
    +  public function cachePerUser() {
    ...
    +  public function cacheUntilEntityChanges(EntityInterface $entity) {
    

    Why aren't these methods on the interface? I understand maybe the cache ones are separate, but allowIf and friends should be somewhere

  3. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,372 @@
    +  /**
    +   * @param \Drupal\Core\Access\AccessResultInterface $other
    +   */
    +  protected function mergeCacheabilityMetadata(AccessResultInterface $other) {
    

    Missing a full docblock

  4. +++ b/core/modules/views/src/ViewsAccessCheck.php
    @@ -31,11 +32,11 @@ public function applies(Route $route) {
    -    return $account->hasPermission('access all views') ? static::ALLOW : static::DENY;
    +    return AccessResult::create()->allowIf($account->hasPermission('access all views'))->cachePerRole();
    

    Taking this as an example of the patch as a whole:

    I really dislike the verbosity of "AccessResult::create()->allowIf()" since it is used so commonly. Was there any thought given to having several factory methods, like AccessResult::allowIf()?

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
277.47 KB
1.21 KB

Thanks for the review!

  1. Fixed.
  2. effulgentsia and I thought we should only put the essential methods on the interface. Then specific implementations can provide whatever DX they see fit for actually setting values. All consumers of AccessResultInterface only need the four that are already on the interface: isAllowed(), isForbidden(), orIf() and andIf().
  3. Fixed.
  4. No, that wasn't considered yet, but that's a great idea! However, while implementing that, I realized that what you describe is impossible: we can't have allowif() be both a static method *and* an instance method. So then I'd have to call it createAndAllowIf()… which only yields a 1-character win:
    return AccessResult::create()->allowIf($condition);
    return AccessResult::createAndAllowIf($condition);
    

    I think we want the instance method because otherwise we lose the ability to "gradually build" an access result (i.e. create an access result, perform if-tests, and depending on which logic branch we end up in, invoke different instance methods on the AccessResult object).
    It is possible though to get rid of that possibility, which would allow us to introduce AccessResult::allowIf(). You're right that it would simplify the most common cases.
    What do you think?

Rebased on top of HEAD (and after I finished, I had to rebase it again :P — HEAD moves fast!).

Crell’s picture

+++ b/core/lib/Drupal/Core/Access/DefaultAccessCheck.php
@@ -21,18 +21,18 @@ class DefaultAccessCheck implements RoutingAccessInterface {
-      return static::ALLOW;
+      return AccessResult::create()->allow();
     }
     elseif ($route->getRequirement('_access') === 'FALSE') {
-      return static::KILL;
+      return AccessResult::create()->forbid();
     }
     else {
-      return static::DENY;
+      return AccessResult::create();

I can't say I like the complexity this adds, but I can't think of an alternative. That said, we could simplify the DX a little here by having return AccessResult::allowed() and AccessResult::kill() and AccessResult::deny(), which simply return a new result object set to that result. It's a bit less typing and I think more natural for people writing access checkers.

Crell’s picture

And that's what happens when I go have dinner between looking at an issue and commenting on it. :-P

Wim, can you clarify for my late-night brain what you mean by "It is possible though to get rid of that possibility, which would allow us to introduce AccessResult::allowIf()"? Get rid of which possibility, and how? It sounds like you mean get rid of the possibility of building up the object gradually, which seems like not something to lose...

Wim Leers’s picture

Issue tags: -Needs tests
FileSize
289.42 KB
99.66 KB
21.88 KB

Chasing HEAD again (broken twice while rerolling — by #1976158: Rename entity storage/list/form/render "controllers" to handlers, #1837388: Provide a ParamConverter than can upcast any entity. and #2314599: Use title/url instead of l() for building breadcrumb this time).


I had a chat about #47.4/#48.4 with tim.plunkett & effulgentsia yesterday. We concluded:

  1. prefix all the setters with "set" (sounds sensible when we put it that way)
  2. make ::allow(), ::forbid(), ::allowIf(), ::forbidIf() static methods.

But… I think the suggestion in #49 is even better. So I went with that instead. I kept the existing AccessResult methods the same, and went with ::allowed() (convenience method for doing ::create()->allow()), ::allowedIf() (::create()->allowIf()), ::forbidden() and ::forbiddenIf(). I checked with effulgentsia and tim.plunkett and they're also both on board with that.


This patch now has 100% test coverage for AccessResult.

All PHPUnit tests pass, hopefully all other tests also pass.

tim.plunkett’s picture

+++ b/core/modules/views/src/ViewsAccessCheck.php
@@ -31,11 +32,11 @@ public function applies(Route $route) {
-    return $account->hasPermission('access all views') ? static::ALLOW : static::DENY;
+    return AccessResult::allowedIf($account->hasPermission('access all views'))->cachePerRole();

Okay, now this looks great! I didn't go through the whole thing again, but I have no more DX concerns.

Status: Needs review » Needs work

The last submitted patch, 51: access_cacheability_metadata-2287071-51.patch, failed testing.

Crell’s picture

+++ b/core/lib/Drupal/Core/Access/DefaultAccessCheck.php
@@ -21,18 +21,18 @@ class DefaultAccessCheck implements RoutingAccessInterface {
   public function access(Route $route) {
     if ($route->getRequirement('_access') === 'TRUE') {
-      return static::ALLOW;
+      return AccessResult::allowed();
     }
     elseif ($route->getRequirement('_access') === 'FALSE') {
-      return static::KILL;
+      return AccessResult::forbidden();
     }
     else {
-      return static::DENY;
+      return AccessResult::create();
     }

Much better! Thanks, Wim!

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
290.41 KB
1.04 KB

All of the fails in #51 were due to not having updated the newly added core/modules/node/src/Access/NodePreviewAccessCheck.php access check yet. This will be green.

Status: Needs review » Needs work

The last submitted patch, 55: access_cacheability_metadata-2287071-55.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
290.43 KB

Assuming HEAD wouldn't have changed too much in the past 14 hours was too big of an assumption.

However, this time, the power of git's automatic 3-way merges saved the day: automatic rebase attached :)

effulgentsia’s picture

Initial review (nits) before dreditor crashes :)

  1. +++ b/core/lib/Drupal/Core/Access/AccessManager.php
    @@ -200,10 +200,13 @@ public function checkNamedRoute($route_name, array $parameters = array(), Accoun
    +      // possible due to highly dynamic circumstances.
    

    Remove "highly".

  2. +++ b/core/lib/Drupal/Core/Access/AccessManager.php
    @@ -234,30 +237,39 @@ public function check(Route $route, Request $request, AccountInterface $account)
    +      // Stop as soon as the first DENY or KILL is encountered.
    

    Can we remove all mention of DENY, KILL, and ALLOWED constants throughout core? Everything should be explained in terms of AccessResultInterface::isAllowed() and isForbidden(). How those are implemented is an implementation choice. Related, let's move the definition of the constants into AccessResult.

  3. +++ b/core/lib/Drupal/Core/Access/AccessManager.php
    @@ -234,30 +237,39 @@ public function check(Route $route, Request $request, AccountInterface $account)
    +        $result = $result->andIf($other);
    

    What about just $result->andIf($other); to not hide from people that the method mutates the subject.

  4. +++ b/core/lib/Drupal/Core/Access/AccessManager.php
    @@ -312,16 +318,17 @@ protected function checkAny(array $checks, $route, $request, AccountInterface $a
    +      throw new AccessException("Access error in $service_id. Access services can only return AccessResultInterface objects.");
    

    "... Access services must return an object that implements AccessResultInterface".

  5. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +    return new AccessResult();
    

    Everywhere in this class: s/AccessResult/static/

  6. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +  public function reset() {
    

    This should either also invoke the other reset*() methods, or else be renamed to resetValue(), resetAccess() or similar.

  7. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +  public function allowIf($condition) {
    +    $this->value = $condition ? AccessInterface::ALLOW : AccessInterface::DENY;
    

    Do we want to overwrite $this->value if $condition is FALSE? Same q for forbidIf().

  8. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +   * AccessResult objects solely return cache context IDs, no 'regular' strings.
    

    What does this mean?

  9. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +   * It's not very useful to cache individual cache results, but the interface
    

    individual access results

  10. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +   * Convenience method, to simply setting the "per role" cache context.
    

    Adds the 'cache_context.user.roles' cache context.

  11. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +   * Convenience method, to simply setting the "per user" cache context.
    

    Adds the 'cache_context.user' cache context.

  12. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +   * Convenience method, to simplify setting an entity's cache tag.
    

    Adds the entity's cache tag.

  13. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +    // If this AccessResult already is forbidden, then that already is the
    +    // conclusion. We can completely disregard $other.
    

    Does this mean order of operations will affect cache metadata (i.e., we end up with a more aggressively cleared cache than we need if the forbidden one is the argument rather than the subject)?

  14. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,438 @@
    +    if ($this->isForbidden() || !$this->isAllowed()) {
    

    Since we're checking !isAllowed(), do we need the isForbidden() check?

effulgentsia’s picture

Re #58.7: Or, what about removing the instance-level allowIf() and forbidIf()? There are few callers of them, and I don't think they'd be made worse by having their own if statement.

effulgentsia’s picture

Got through core/lib. Still need to review core/modules and core/tests, if no one else does.

  1. +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
    @@ -74,10 +75,11 @@ public function access(EntityInterface $entity, $operation, $langcode = Language
    -    if (($return = $this->processAccessHookResults($access)) === NULL) {
    +    $return = $this->processAccessHookResults($access);
    +    if (!$return->isAllowed() && !$return->isForbidden()) {
           // No module had an opinion about the access, so let's the access
    -      // handler check create access.
    -      $return = (bool) $this->checkAccess($entity, $operation, $langcode, $account);
    +      // handler check access.
    +      $return = $this->checkAccess($entity, $operation, $langcode, $account);
         }
    

    If we enter the if statement, shouldn't we still ->orIf() instead of overwrite, since the lack of module opinion might be conditional on something dynamic?

  2. +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
    @@ -122,19 +128,19 @@ protected function processAccessHookResults(array $access) {
         if ($operation == 'delete' && $entity->isNew()) {
    -      return FALSE;
    +      return AccessResult::forbidden()->cacheUntilEntityChanges($entity);
         }
         if ($admin_permission = $this->entityType->getAdminPermission()) {
    -      return $account->hasPermission($admin_permission);
    +      return AccessResult::allowedIf($account->hasPermission($this->entityType->getAdminPermission()))->cachePerRole();
         }
    

    Where is it communicated that the result also depends on $entity->id()? Or is it up to the caller to know that (e.g., for menu links, not a problem, but not sure about other use cases). Same question might apply to other access checkers and their various arguments, this is just the one that caught my attention.

  3. +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
    @@ -224,10 +229,11 @@ public function createAccess($entity_bundle = NULL, AccountInterface $account =
    +    if (!$return->isAllowed() && !$return->isForbidden()) {
           // No module had an opinion about the access, so let's the access
           // handler check create access.
    -      $return = (bool) $this->checkCreateAccess($account, $context, $entity_bundle);
    +      $return = $this->checkCreateAccess($account, $context, $entity_bundle);
         }
    

    Same as above re needing an ->orIf() instead of overwrite.

  4. +++ b/core/lib/Drupal/Core/Menu/MenuLinkDefault.php
    @@ -94,7 +95,7 @@ public function getDescription() {
    -    return (bool) $this->staticOverride->loadOverride($this->getPluginId());
    +    return AccessResult::allowedIf($this->staticOverride->loadOverride($this->getPluginId()))->setCacheable(FALSE);
    

    Is the setCacheable(FALSE) because we don't have cache tags for non-entity config objects? If so, a @todo would be nice. Related: #2040135-4: Caches dependent on simple config are only invalidated on form submissions.

dawehner’s picture

Issue summary: View changes

update the IS to include the actual outside API change.

Wim Leers’s picture

FileSize
299.54 KB
40.22 KB

#58 & #59: All done!

Details where necessary below.


#58.2: perhaps we do want a AccessResultInterface::hasNoOpinion() method, which would be a convenience method replacing !isAllowed() && !isForbidden()? Similarly, perhaps we should rename AccessResultInterface::reset() to ::resetOpinion()?

#58.6: done, changed to ::resetAccess().

#58.7: Excellent question! I think you're right, we don't want to overwrite $this->value, because that makes allowIf() easier to understand. Then it's literally about conditionally calling allow(), and nothing else.
Test coverage updated.

#58.8: Clarified; also updated CacheableInterface::getCacheKeys()'s docblock — thereby making the language consistent. Any further updates in this area should happen in a follow-up, because it's scope creep. I've opened up #2329101: CacheableInterface only has a getCacheKeys() method, no getCacheContexts(), leads to awkward implementations to refine that further.

#58.13: Yes, order matters. And for good reason: if a decision has already been made, then any subsequent orIf() calls don't affect the access result. This is nothing new.

#58.14: Yes, we do need that check. The andIf() check can (and must) terminate if the current access result's value is not ALLOW, since all access results must be ALLOW for the combined end result still to achieve ALLOW. Therefore we want to return early if either the current access result is not explicitly allowed (!isAllowed(), i.e. DENY) or if it's explicitly forbidden (isForbidden(), i.e. KILL), because the combination of those checks allow us to know that it is indeed explicitly allowed (ALLOW), in which case we can apply the logic to combine the other access result's value.
Try changing it and you'll get a test failure.

#59.1/3: Excellent catch! Fixed.

#59.2: I don't understand the question. An access result cannot depend on an entity's ID. It can depend on an entity. And if that's the case, we use ::addCacheTags($entity->getCacheTag()), for which ::cacheUntilEntityChanges($entity) is the shorthand/convenience method.

#59.4: Wow, amazing catch! You're right. Added a @todo.

Status: Needs review » Needs work

The last submitted patch, 62: access_cacheability_metadata-2287071-61.patch, failed testing.

Wim Leers’s picture

Queued for re-testing because I cannot reproduce the 4 test failures locally. Fingers crossed.

The last submitted patch, 62: access_cacheability_metadata-2287071-61.patch, failed testing.

dawehner’s picture

To tackle the accidental boolean issue we should keep the existing methods (and return boolean), the existing access manager interface
but add a new one AccessManagerWithCacheContext which adds methods which returns the access objects. This ensures that we don't accidentaly leak information

effulgentsia’s picture

Queued for re-testing because I cannot reproduce the 4 test failures locally.

FWIW, I get the same 4 failures when running locally via the Simpletest web UI when applying #62 on top of core commit 2191f864ab27e8457c25cbeb4e0fe2a5b644a263. I didn't try via run-tests.sh.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
289.46 KB

#2323721: [sechole] Link field item and menu link information leakage broke this, rerolled. On 68755a3bbfaef7b2507bb5c053a631e9f3b320cc now (latest as of right now), and still can't reproduce this. Here's hoping that it will now pass.

Note: I'm on PHP 5.5.11.

Wim Leers’s picture

Ignore #69. It's rerolled on top of #57 rather than #62. Sorry for the noise.

Wim Leers’s picture

4 test failures reproduced now. Was all my fault; I'd been testing with #57 all along… stupid! :(

Should be green now.

The last submitted patch, 69: access_cacheability_metadata-2287071-69.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 71: access_cacheability_metadata-2287071-71.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
299.01 KB
843 bytes

LOL, yet another commit that broke this: #2188675: "Translate" local task always visible, leads to WSOD.

effulgentsia’s picture

  1. +++ b/core/modules/block/block.api.php
    @@ -152,8 +155,11 @@ function hook_block_access(\Drupal\block\Entity\Block $block, $operation, \Drupa
       if ($operation == 'view' && $block->get('plugin') == 'system_powered_by_block' && $block->get('region') != 'footer') {
    -    return FALSE;
    +    return AccessResult::forbidden();
    

    Does this need cache tags added for $block?

  2. +++ b/core/modules/comment/src/CommentAccessControlHandler.php
    @@ -23,24 +24,23 @@ class CommentAccessControlHandler extends EntityAccessControlHandler {
    +      default:
    +        // No opinion.
    +        return AccessResult::create();
    

    Before the switch statement, we checked a permission, so this should be cached per role.

  3. +++ b/core/modules/comment/src/Tests/CommentTranslationUITest.php
    @@ -153,7 +153,8 @@ function testTranslateLinkCommentAdminPage() {
    -    $this->assertNoLinkByHref('comment/' . $cid_untranslatable . '/translations');
    +    // @todo uncomment this, I just needed to be able to finally post this patch, no more energy for debugging
    +//    $this->assertNoLinkByHref('comment/' . $cid_untranslatable . '/translations');
    

    Time to debug this.

  4. +++ b/core/modules/content_translation/src/Access/ContentTranslationManageAccessCheck.php
    @@ -86,24 +87,26 @@ public function access(Route $route, Request $request, AccountInterface $account
    +          $is_new_translation = $condition = ($source_language->getId() != $target_language->getId()
    

    Why the $condition intermediary?

  5. +++ b/core/modules/field/tests/modules/field_test/field_test.field.inc
    @@ -40,14 +41,14 @@ function field_test_default_value(ContentEntityInterface $entity, FieldDefinitio
       if ($field_definition->getName() == "field_no_{$operation}_access") {
    -    return FALSE;
    +    return AccessResult::forbidden()->cacheUntilEntityChanges($items->getEntity());
       }
     
       // Only grant view access to test_view_field fields when the user has
       // 'view test_view_field content' permission.
       if ($field_definition->getName() == 'test_view_field' && $operation == 'view' && !$account->hasPermission('view test_view_field content')) {
    -    return FALSE;
    +    return AccessResult::forbidden()->cachePerRole()->cacheUntilEntityChanges($items->getEntity());
       }
     
    -  return TRUE;
    +  return AccessResult::allowed();
    

    Why add cache tags for $items->getEntity(). But, we will want cache tags for $field_definition: that can be a @todo followup though. Also, the allowed() needs to be per role too, right?

  6. +++ b/core/modules/node/node.pages.inc
    @@ -37,3 +37,68 @@ function template_preprocess_node_add_list(&$variables) {
    +function node_preview(NodeInterface $node, FormStateInterface $form_state) {
    +function template_preprocess_node_preview(&$variables) {
    

    Rogue hunks from a different issue?

  7. +++ b/core/modules/system/entity.api.php
    @@ -1860,13 +1860,14 @@ function hook_entity_field_access($operation, \Drupal\Core\Field\FieldDefinition
    +    $grants['node']->reset();
    

    resetAccess()

  8. +++ b/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php
    @@ -116,10 +116,11 @@ public function testEntityViewBuilderCacheWithReferences() {
    -    drupal_render($build);
    -
    -    // Test that a cache entry is created.
    -    $this->assertTrue($this->container->get('cache.' . $bin)->get($cid), 'The entity render element has been cached.');
    +// @todo This causes an exception that I still need to finish debugging the root cause for. Disabled for now, to make the patch reviewable.
    +//    drupal_render($build);
    +//
    +//    // Test that a cache entry is created.
    +//    $this->assertTrue($this->container->get('cache.' . $bin)->get($cid), 'The entity render element has been cached.');
    

    Time to fix.

  9. +++ b/core/modules/user/src/Access/RegisterAccessCheck.php
    @@ -21,10 +22,10 @@ class RegisterAccessCheck implements AccessInterface {
    -    return ($account->isAnonymous()) && (\Drupal::config('user.settings')->get('register') != USER_REGISTER_ADMINISTRATORS_ONLY) ? static::ALLOW : static::DENY;
    +    return AccessResult::allowedIf($account->isAnonymous() && \Drupal::config('user.settings')->get('register') != USER_REGISTER_ADMINISTRATORS_ONLY)->cachePerRole();
    

    If depending on config, then not cacheable until config has cache tags.

effulgentsia’s picture

Status: Needs review » Needs work
Issue tags: +Needs issue summary update

Issue summary should also be updated to not reference CacheabilityAffectorInterface, which isn't in this patch anymore. And it would be good to make sure the API changes section lists all interfaces affected (e.g., EntityAccessControlHandlerInterface, AccessibleInterface, AccessManagerInterface, what else?).

tstoeckler’s picture

Thanks for your persistence on this @Wim Leers!

  1. +++ b/core/lib/Drupal/Core/Block/BlockBase.php
    @@ -165,10 +166,22 @@ public function access(AccountInterface $account) {
    -    return $this->blockAccess($account);
    +    if ($this->blockAccess($account)) {
    +      $access->allow();
    +    }
    +    else {
    +      $access->forbid();
    +    }
    +    return $access;
    

    Since this is a common pattern in this patch I wonder if we can improve the DX for this now that we have a rich object to work with anyway. Maybe
    $access->allowIfEslseForbid()?

  2. +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php
    @@ -122,19 +128,19 @@ protected function processAccessHookResults(array $access) {
    -      return $account->hasPermission($admin_permission);
    +      return AccessResult::allowedIf($account->hasPermission($this->entityType->getAdminPermission()))->cachePerRole();
    

    This is also common in this patch. Also I think this will be one the most common use-cases in contrib, so I think we shouldn't make people ponder about cacheability when we already know that permissions are tied to roles anyway. Can we add a
    $access->allowedIfHasPermission($account, $permission) or similar which just performs the two function calls internally?

Wim Leers’s picture

#75/#76: thanks, will reroll!

#77:

  1. This is not a common pattern at all? It occurs two times in the entire patch.
  2. This indeed does occur a lot of times, and it's the most typical access checking pattern in Drupal core & contrib. I'll talk to effulgentsia in chat and if he agrees with that as well, I'll add this convenience method also.
Wim Leers’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs issue summary update
FileSize
298.49 KB
12.72 KB

#75:

  1. Excellent catch. Fixed!
  2. Fixed.
  3. Fixed.
  4. Good catch, that's a leftover from an earlier patch iteration.
  5. You're right — because in these cases, the cache tag is not necessary, because no entity *data* is used, only entity *structure* (the name of the fields, not their contents). It's impossible to change field names once the system is in use; it can only be changed by migration, so a field definition cache tag is unnecessary.
  6. Oh my! Yes, these come from #1510544: Allow to preview content in an actual live environment.
  7. Fixed.
  8. Managed to fix this one — the exception is thrown because the default entity reference formatter (the label one) is used, which also links by default. And generating that URL to link to fails because this is a KernelTestBase test that doesn't have routing stuff installed. So I just disabled the "linking" setting, and now it passes. I wonder how this used to pass before though…
  9. Sad, but true! Fixed.

#76: Done.

This reroll does not yet address #78.

Wim Leers’s picture

FileSize
299.93 KB
21.08 KB
  • I discussed #77.1 with @tstoeckler in IRC. He agrees that it's moot since there's only 2 occurrences.
  • I also discussed #77.2 with effulgentsia. He agrees that it's a sensible addition, that indeed improves DX.
  • Therefore, this reroll addresses #77.2: it introduces
    1. ::allowIfHasPermission() as a convenience instance method
    2. ::allowedIfHasPermission() as a convenience static method

    (consistent with other convenience methods)

I also made one mistake in #79 — DbUpdateAccessCheck allows for a settings.php override, and of course if that's the case, the access result should not be cacheable, because settings.php can change at any time. So I marked it as uncacheable now.

The last submitted patch, 79: access_cacheability_metadata-2287071-79.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 80: access_cacheability_metadata-2287071-80.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
300.1 KB
2.34 KB

And green again.

effulgentsia’s picture

To tackle the accidental boolean issue we should keep the existing methods (and return boolean), the existing access manager interface
but add a new one AccessManagerWithCacheContext which adds methods which returns the access objects.

I disagree with this. The issue summary now lists all the different methods/functions that return an access check result. Why would we want to single out AccessManager::check()/checkNamedRoute() as following different rules than the others? I think it's cleaner for everything that returns an access check result to return an AccessResultInterface, which this patch does. Yes, that does mean that callers will need to explicitly call isAllowed(), since if ($access_result) always returns TRUE, but I think it's more likely that developers will remember to call isAllowed() if they learn that they always need to do that, rather than needing to learn when they do and when they don't.

I think the patch is RTBC other than reaching consensus on this point. @dawehner: thoughts?

dawehner’s picture

@effulgentsia
Well, your argument would certainly be 100% fine in case there would be just Drupal and we would just develop for Drupal, but well, in case we would
try to write generic components, which we don't do, the code would no longer follow the rule of clean code:

 truly clean code does pretty much what you expect it to do
Yes, that does mean that callers will need to explicitly call isAllowed(), since if ($access_result) always returns TRUE, but I think it's more likely that developers will remember to call isAllowed() if they learn that they always need to do that, rather than needing to learn when they do and when they don't.

Well, let's hope developers read documentation before they write code, examples clearly show that already, right ;) ? I hope though that we still have security first
as our concept, over performance/cachablity, in case no, good luck!

+ */
+
+namespace Drupal\Core\Access;
+
+/**
+ * Interface for checking access.
+ *
+ * Note: you can check whether access is neither explicitly allowed nor
+ * explicitly forbidden:
+ *
+ * @code
+ * $no_opinion = !$access->isAllowed() && !$access->isForbidden();
+ * @endcode
+ */
+interface AccessResultInterface {

So this interface checks access, really? What about the following change:

Represents the outcome of access checking.

IMPORTANT NOTE: You have to call isAllowed() when you want to know whether someone has access. Just using
if ($access_result) would always succeed and so introduce a critical security issue.

Well, anyway, don't feel blocked on me, just want to ensure that we really don't treat some values over others.

chx’s picture

Status: Needs review » Needs work

Stop this madness now. There was a saying in a usability book (by Jef Raskin): if a control always needs to be operated do not provide that control!

If you always need to call a method then do not provide that method.

This is absolutely not acceptable and dawehner is right.

effulgentsia’s picture

Let's say I want to do some logic based on if I have any nodes. This won't work:

if (\Drupal::entityQuery('node')->count()) {
  // There are nodes. Do something.
}
else {
  // No nodes. Do something else.
}

Because that if will always evaluate to true. Instead, I need to do:

if (\Drupal::entityQuery('node')->count()->execute()) {
  // There are nodes. Do something.
}
else {
  // No nodes. Do something else.
}

Why do I need the explicit call to execute()? Because there's something else I might want to do with a query object: execute() isn't the only method. Even though in practice, what do I usually want to do with a count query other than execute it?

Same thing here. What this issue is about is making access check results not just booleans (or tristate constants), but objects, so that callers can know more than just whether access is allowed or not, but also what that result depends on (i.e., cache contexts and tags). Unfortunately, PHP doesn't allow an object to implement a magic __toBoolean() method. Per the issue summary, there are many interfaces/methods that return an access result. Does that mean we want all of them to have a "return as boolean" and "return as object" variants? Or, can we trust that developers can learn that access results are objects on which you can call isAllowed(), just like they learned that queries are objects on which you can call execute()?

chx’s picture

Status: Needs work » Needs review

> Why do I need the explicit call to execute()? Because there's something else I might want to do with a query object

And there's nothing you can even do to the AccessResultInterface. The query object has a large amount of methods that significantly change its behavior and so it needs an execution. There is nothing like that here.

You guys long, long ago have lost any connection with reality. I am trying to drag back Drupal 8 to the ground but it's hard and if this goes in it'll be even harder.

And yet, I know there's nothing I can do. So resetting to code needs review as I know there is nothing I would say or do would change it. D8 is a lost cause. There was a brief hope when I was allowed to make it secure. Apparently that window is closed.

effulgentsia’s picture

I hope though that we still have security first as our concept, over performance/cachablity

We need both. The most secure thing an internet user can do is unplug their computer from the internet. But we don't really tell people that they can't use the internet because there are security dangers out there. Similarly, we shouldn't have to tell people that their sites must be slow, because we're afraid to implement a performance improvement that requires developers to start dealing with an object where once they dealt with booleans, because of a fear that dealing with objects has a pathway by which it's dealt with incorrectly.

But, if we really think it's dangerous DX to return an object from a method that for some reason people expect a boolean from, then I'm fine with something like #67 where we have separate methods, or an extra parameter (e.g., $return_as_object) that defaults to FALSE. But then my question is which of our many access methods do we provide that variant on? Is there any reason to do it on AccessManager::checkNamedRoute() and not do it on Entity::access()? And if we do it on those two, which other ones do we need to add to the "important to babysit callers" list?

chx’s picture

I see two resolutions: either #67 or won't fix the issue. Anything else results in an insecure Drupal 8. I am astounded people don't see this.

effulgentsia’s picture

And there's nothing you can even do to the AccessResultInterface

It's not just about AccessResultInterface. It's that an object that implements AccessResultInterface can also implement other interfaces (such as CacheableInterface), so callers that receive objects can do a lot more than if only receiving a boolean.

chx’s picture

>> I hope though that we still have security first as our concept, over performance/cachablity

> We need both.

NO WE DONT. Security is our number one concern trumping everything, including performance, usability and everything else. Drupal's history is full of these: db_select is a Drupal-only slow query builder. The form API CSRF token made AJAX hard. And so on and so on. We need to stay on this road. If we don't it's the end of Drupal and I am not kidding. What's your value proposition over Wordpress or Symfony if you are not secure?

effulgentsia’s picture

Status: Needs review » Needs work

Ok, here's my suggestion then:

Add an optional $return_as_object = FALSE parameter to:
- AccessManagerInterface::check()
- AccessManagerInterface::checkNamedRoute()
- AccessibleInterface::access()
- EntityAccessControlHandlerInterface::access()
- EntityAccessControlHandlerInterface::createAccess()
- EntityAccessControlHandlerInterface::fieldAccess()

And that's it. In other words, not the hooks, individual route access checkers, or lower level APIs like NodeGrantDatabaseStorageInterface, since the casual callers I think we're most worried about shouldn't be calling those directly, but via one of the above high-level APIs. It means each of the above implementations will need a:

return $return_as_object ? $result : $result->isAllowed();

line at the end, but that's not so bad.

@dawehner, @chx, @Wim: how does that sound?

chx’s picture

I like that! Especially with the FALSE default... Thanks Alex!

dawehner’s picture

It is certainly a worse DX but security is king.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
271.21 KB
107.74 KB
39.83 KB
69.73 KB

DX/complexity

The last few comments consider this patch as introducing only complexity. As worsening the DX. The opposite is true.

HEAD uses three kinds of tri-state values! If it were using only booleans, I'd agree with the DX regression. But it's not.

All access hooks and some interfaces — most notably Drupal\Core\Access\AccessibleInterface::access() (which is the interface for $entity->access()) — are returning those three kinds of tri-state values:

  1. AccessInterface::(ALLOW|DENY|KILL)
  2. NODE_ACCESS_ALLOW/NODE_ACCESS_DENY/NODE_ACCESS_IGNORE
  3. TRUE/FALSE/NULL

This patch simplifies and unifies all of them (plus the ones that do return booleans) to the same single concept: returning AccessResultInterface.

Compromise

I've implemented #93 — it looks like it's the only possible compromise (#67 would be incomplete/inconsistent, #83 (latest patch) clearly is not acceptable, #93 addresses both).
This definitely does improve the DX in the calling code (mostly $entity->access() calls), at the cost of less consistency, which I think is justified by the gains in this case.

In this reroll, I also addressed dawehner's rightfully critical feedback in #85 regarding the wrong one-line sentence in the new interface's docblock.

I provided three interdiffs to simplify reviews:

  1. interdiff.txt contains all changes
  2. interdiff-interface_and_implementation_changes.txt contains the changes described by #93: the changes in the interfaces and the consequent changes in the implementations (plus of course test coverage updates, to test both $return_as_object === TRUE and $return_as_object === FALSE)
  3. interdiff-callers_updates.txt contains the changes to callers, because the changes in #93 now allow me to remove a lot of changes in earlier patches; the resulting patch now has to change fewer files

So: interdiff 1 = interdiff 2 + interdiff 3. Hope that helps.

effulgentsia’s picture

Assigned: Wim Leers » catch
Status: Needs review » Reviewed & tested by the community

Looks good. Let's see what catch thinks.

chx’s picture

-        $form[$name]['#access'] = $items->access('edit')->isAllowed();
+        $form[$name]['#access'] = $items->access('edit');

I am so happy about this change. I so wish I didn't need to throw a hissy fit to get it and I am sorry I did.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 96: access_cacheability_metadata-2287071-96.patch, failed testing.

chx’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
264.04 KB

Rerolled because Wim complained in another issue that this one is a pain to reroll. I like to save Wim some pain :)

Wim Leers’s picture

Thank you very much chx! :)

catch’s picture

Wasn't sure about this at the start, but the overall architecture makes sense now and the API itself I quite like.

Haven't got through the entire patch, few small things I noticed going through:

  1. +++ b/core/lib/Drupal/Core/Access/AccessResult.php
    @@ -0,0 +1,486 @@
    +   *
    +   * It's not very useful to cache individual access results, but the interface
    +   * forces us to implement this method, so just use the default cache bin.
    +   */
    +  public function getCacheBin() {
    +    return 'default';
    +  }
    

    Should we look at splitting the cache bin method to an additional interface somewhere? This has come up before as well.

  2. +++ b/core/lib/Drupal/Core/Access/AccessResultInterface.php
    @@ -0,0 +1,79 @@
    + *
    + * Note: you can check whether access is neither explicitly allowed nor
    + * explicitly forbidden:
    + *
    + * @code
    

    Not sure the neither nor is grammatically correct here. Isn't it "not explicitly allowed, or explicitly forbidden"?

  3. +++ b/core/lib/Drupal/Core/Access/AccessResultInterface.php
    @@ -0,0 +1,79 @@
    +   * Combine this access result with another using OR.
    

    Looks like a copy and paste error - this is for the and method.

Wim Leers’s picture

Glad you like it! :)

  1. This has indeed come up before. Every single time though, the conclusion in discussions has been Let's not add an additional interface, let's use CacheableInterface everywhere. I agree we should look into that though, so created an issue just for that: #2339373: Remove getCacheBin() from CacheableInterface.
  2. Well, searching the internet for the proper grammar rules seems to confirm that what's currently there is correct: http://thewritepractice.com/how-to-use-either-neither-or-and-nor-correctly/, http://esl.about.com/od/grammarintermediate/a/cm_bothand.htm
  3. Good call. Will fix once you finish your review! (Or perhaps you can fix this on commit, if it's the only thing?)
alexpott’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
5.55 KB
262.26 KB
  1. +++ b/core/modules/shortcut/src/ShortcutAccessControlHandler.php
    @@ -58,6 +59,9 @@ protected function checkAccess(EntityInterface $entity, $operation, $langcode, A
    +    // @todo Fix this bizarre code: how can a shortcut exist without a shortcut
    +    // set? The above if-test is unnecessary.
    +    return AccessResult::create()->cacheUntilEntityChanges($entity);
    

    The @todo needs an issue

  2. +++ b/core/modules/shortcut/src/ShortcutAccessControlHandler.php
    @@ -67,6 +71,9 @@ protected function checkCreateAccess(AccountInterface $account, array $context,
    +    // @todo Fix this bizarre code: how can a shortcut exist without a shortcut
    +    // set? The above if-test is unnecessary.
    +    return AccessResult::create()->cacheUntilEntityChanges($entity);
    

    $entity does not exist

Wim Leers’s picture

FileSize
264.3 KB
6.8 KB

Fixed:

  1. #102.3
  2. #104.1
  3. #104.2
  4. In IRC, alexpott pointed out that the return value documentation for the methods that now have a $return_as_object parameter could be clearer. So clarified that.
Wim Leers’s picture

Issue summary: View changes
FileSize
264.42 KB
1.51 KB

While reviewing, alexpott noticed that in some cases, return FALSE has been replaced with return AccessResult::create().

But that doesn't really change the logic. ALL of these are the same case: oh my logic didn't care? Then let's return FALSE, just in case. Many (if not most) people didn't know about the tri-stateness of access checks: they thought you'd have to return either TRUE or FALSE — they didn't know about NULL. This is new in Drupal 8 after all, and wasn't very well documented. Plus, any existing access checking code already used booleans, and many (most?) were not updated.
By changing that to AccessResult::create() (instead of AccessResult::forbidden(), we effectively say "we don't forbid but we also don't allow", and hence allow contrib modules to interject and make decisions that core modules don't make because they don't care about certain use cases.

E.g. FileAccessControlHandler::checkAccess() looked like this:

protected function checkAccess(EntityInterface $entity, $operation, $langcode, AccountInterface $account) {
  if ($operation == 'download') {
    // Logic to determine whether access is granted or not.
    return $result;
  }

  return FALSE;

This prevented contrib modules from implementing access checking logic for files for operations other than "download": the default controller simply always forbade access. This patch changes that return FALSE at the end to return AccessResult::create(), which gives contrib modules the freedom to implement access checkers for operations core doesn't care about.

This is the case for the following:

  1. LanguageAccessControlHandler
  2. shortcut_set_edit_access()
  3. shortcut_set_switch_access()
  4. ShortcutSetAccessControlHandler
  5. UserAccessControlHandler
  6. FileAccessControlHandler.
  7. MenuLinkContentAccessControlHandler

IS and CR (https://www.drupal.org/node/2337377/revisions/view/7621951/7634851) updated to clarify this.

alexpott is fine with the above, so with that, plus the tirival changes in #104, #105 and this patch, we can move this back to RTBC as soon as testbot confirms there are no test failures.

(Two more cases were also changed but are reverted in this reroll: one was accidental, the other one needs further discussion.)

Wim Leers’s picture

Issue summary: View changes
alexpott’s picture

Status: Needs review » Reviewed & tested by the community

I read the patch over twice once for nits and to get a feel for it and once with a security head on to ensure that everything that has changed is acceptable. Thanks @Wim Leers for your patience on IRC :)

The patch looks really good to me and the DX win of AccessResultInterface is huge. Let's get this in to unblock several other changes.

catch’s picture

Status: Reviewed & tested by the community » Fixed

OK I'm happy with this as well now. AccessResultInterface is a good improvement in itself, then we just add the caching information on top.

Committed/pushed to 8.0.x, thanks!

  • catch committed 0f28b51 on 8.0.x
    Issue #2287071 by Wim Leers, alexpott, chx: Add cacheability metadata to...
Wim Leers’s picture

Woot, this unblocks #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees and #2232609: Cacheable breadcrumbs block, and fix breadcrumb builders! :) And I'm sure more things will come up in the future.

So glad this landed, because it's indeed a big improvement for both the cacheability and the developer experience of Drupal 8! :)

fago’s picture

While looking through the changes I noticed a few glitches and small DX issue, thus I created a quick follow-up: #2341399: Follow-up: entity.api.php documentation fixes.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

olli’s picture

+++ b/core/lib/Drupal/Core/Menu/MenuLinkBase.php
@@ -76,7 +77,7 @@ public function isExpanded() {
   public function isResettable() {
-    return FALSE;
+    return AccessResult::forbidden();
   }

#2373017: No delete link when editing a menu, only reset links