AccessResult::andIf() loses cacheability info when a neutral result involved

Created on 5 June 2024, 3 months ago

Problem/Motivation

This is a spin-off issue of πŸ› Insufficient cacheability information bubbled up by UserAccessControlHandler Active where it turned out that cacheability information lost AccessResult::allowed()->andIf(AccessResult::neutral()) is called.

@catch suggested to make the following refactoring of the current state of https://git.drupalcode.org/project/drupal/-/merge_requests/8285. When the change was made, suddenly the user:[uid] cache tag get lost and tests identified that.

diff --git a/core/modules/user/src/UserAccessControlHandler.php b/core/modules/user/src/UserAccessControlHandler.php
index b5c51a6ae2077411b573e26ee6c3f56642e3c2c3..dfd79a94c5c3ef4a7ae088be28f480998243b335 100644
--- a/core/modules/user/src/UserAccessControlHandler.php
+++ b/core/modules/user/src/UserAccessControlHandler.php
@@ -3,9 +3,7 @@
 namespace Drupal\user;
 
 use Drupal\Core\Access\AccessResult;
-use Drupal\Core\Access\AccessResultNeutral;
 use Drupal\Core\Access\AccessResultReasonInterface;
-use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityAccessControlHandler;
 use Drupal\Core\Field\FieldDefinitionInterface;
@@ -51,19 +49,21 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter
 
     switch ($operation) {
       case 'view':
-        $view_cacheability = (new CacheableMetadata())->addCacheContexts(['user.permissions'])->addCacheableDependency($entity);
         // Only allow view access if the account is active.
-        if ($account->hasPermission('access user profiles') && $entity->isActive()) {
-          return AccessResult::allowed()->addCacheableDependency($view_cacheability);
-        }
+        $result = AccessResult::allowedIfHasPermission($account, 'access user profiles');
 
-        // Users can view own profiles at all times.
-        $view_cacheability->addCacheContexts(['user']);
-        if ($account->id() == $entity->id()) {
-          return AccessResult::allowed()->addCacheableDependency($view_cacheability);
+        if ($result->isAllowed()) {
+          $result = $result->andIf(
+            AccessResult::allowedIf($entity->isActive())->addCacheableDependency($entity)
+          )->setReason("The 'access user profiles' permission is required and the user must be active.");
+
+          if ($result->isAllowed()) {
+            return $result;
+          }
         }
 
-        return AccessResultNeutral::neutral("The 'access user profiles' permission is required and the user must be active.")->addCacheableDependency($view_cacheability);
+        // Users can view own profiles at all times.
+        return $result->orIf(AccessResult::allowedIf($account->id() == $entity->id())->addCacheContexts(['user']));
 
       case 'update':
         // Users can always edit their own account.

Steps to reproduce

Failing tests are under cooking...

Proposed resolution

Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

πŸ› Bug report
Status

Closed: works as designed

Version

11.0 πŸ”₯

Component
BaseΒ  β†’

Last updated less than a minute ago

Created by

πŸ‡­πŸ‡ΊHungary mxr576 Hungary

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @mxr576
  • πŸ‡­πŸ‡ΊHungary mxr576 Hungary
  • πŸ‡­πŸ‡ΊHungary mxr576 Hungary
  • πŸ‡ΊπŸ‡ΈUnited States cilefen
  • πŸ‡§πŸ‡ͺBelgium kristiaanvandeneynde Antwerp, Belgium

    Are you sure though? AccessResult::andIf() has 3 branches, we are dealing with the third.

        if ($this->isForbidden() || $other->isForbidden()) {
        }
        elseif ($this->isAllowed() && $other->isAllowed()) {
        }
        else {
         // WE ARE HERE WHEN ALLOWED+NEUTRAL.
        }
    

    In said branch, we pass the following check:

          if (!$this->isNeutral()) {
            $merge_other = TRUE;
            if ($other instanceof AccessResultReasonInterface) {
              $result->setReason($other->getReason());
            }
          }
    

    So when you have allowed and use andIf to feed in a neutral, you should get the cacheability from the neutral access result. This makes sense because A+N could turn into A+A under different cache context values, and that would lead to a different outcome (Allowed).

    However, when you have neutral and feed in allowed, then the second one doesn't get merged in. But that makes sense as the first one is N and we know the second one can't be F. N+N and N+A both return N based on the first N, so why even bother with the second result?

    So I don't see a bug in the code. Am I missing something?

  • πŸ‡­πŸ‡ΊHungary mxr576 Hungary

    You are right, I went back time, made the code more explicit and I we hit the N+A scenario that was not clearly visible on the original screenshot.

    At first I could not reproduce with the latest, more explicit code in https://git.drupalcode.org/project/drupal/-/merge_requests/8285

            // Only allow view access if the account is active.
            $result = AccessResult::allowedIfHasPermission($account, 'access user profiles');
    
            if ($result->isAllowed()) {
              $result = $result->andIf(
                AccessResult::allowedIf($entity->isActive())->addCacheableDependency($entity)
              );
    
              if ($result instanceof AccessResultReasonInterface) {
                $result->setReason("The 'access user profiles' permission is required and the user must be active.");
              }
    
              if ($result->isAllowed()) {
                return $result;
              }
            }
    
            // Users can view own profiles at all times.
            return $result->orIf(AccessResult::allowedIf($account->id() == $entity->id())->addCacheContexts(['user']));
    
    

    and I realized why... thanks to the more explicit code that only handles the A+N scenario and does not expect the the user:3 cache tag appear in every cases.

       protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
         // @see \Drupal\user\UserAccessControlHandler::checkAccess()
    -    return parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated)
    -      ->addCacheTags(['user:3']);
    

    Although, this could also mean that A+A scenario is not tested, at least not part of the User related REST tests.

    If you agree, we can close this ticket.

  • Status changed to Closed: works as designed 3 months ago
  • πŸ‡§πŸ‡ͺBelgium kristiaanvandeneynde Antwerp, Belgium

    Yup, mistakes happen. Glad we figured it out.

Production build 0.71.5 2024