diff --git a/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php index 1ccee03f856bec33a85f60c9a459c683b6a9e678..ba268af126595d6c29af6dd26d12ebfcb1455003 100644 --- a/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php +++ b/core/lib/Drupal/Core/Menu/DefaultMenuLinkTreeManipulators.php @@ -212,22 +212,14 @@ protected function collectNodeLinks(array &$tree, array &$node_links) { * The access result. */ protected function menuLinkCheckAccess(MenuLinkInterface $instance) { - $access_result = NULL; - if ($this->account->hasPermission('link to any page')) { - $access_result = AccessResult::allowed(); - } - else { - $url = $instance->getUrlObject(); + $url = $instance->getUrlObject(); - // When no route name is specified, this must be an external link. - if (!$url->isRouted()) { - $access_result = AccessResult::allowed(); - } - else { - $access_result = $this->accessManager->checkNamedRoute($url->getRouteName(), $url->getRouteParameters(), $this->account, TRUE); - } + if ($url->isRouted()) { + return $this->accessManager->checkNamedRoute($url->getRouteName(), $url->getRouteParameters(), $this->account, TRUE); } - return $access_result->cachePerPermissions(); + + // Must be an external link. + return AccessResult::allowed(); } /** diff --git a/core/modules/menu_ui/menu_ui.services.yml b/core/modules/menu_ui/menu_ui.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..79c75b803d4a6abb14f882c2ba084aa9e4c7d6b6 --- /dev/null +++ b/core/modules/menu_ui/menu_ui.services.yml @@ -0,0 +1,4 @@ +services: + menu_ui.menu_tree_manipulators: + class: Drupal\menu_ui\Menu\MenuUiMenuTreeManipulators + Drupal\menu_ui\Menu\MenuUiMenuTreeManipulators: '@menu_ui.menu_tree_manipulators' diff --git a/core/modules/menu_ui/src/Menu/MenuUiMenuTreeManipulators.php b/core/modules/menu_ui/src/Menu/MenuUiMenuTreeManipulators.php new file mode 100644 index 0000000000000000000000000000000000000000..0fbba4a803c99f3a813904b993a052f3f091adaa --- /dev/null +++ b/core/modules/menu_ui/src/Menu/MenuUiMenuTreeManipulators.php @@ -0,0 +1,49 @@ +<?php + +namespace Drupal\menu_ui\Menu; + +use Drupal\Core\Access\AccessResult; + +/** + * Provides menu tree manipulators to be used when managing menu links. + */ +class MenuUiMenuTreeManipulators { + + /** + * Grants access to a menu tree when used in the menu management form. + * + * This manipulator allows access to menu links with inaccessible routes. + * + * Example use cases: + * - A login menu link, using the `user.login` route, is not accessible to a + * logged-in user, but the site builder still needs to configure the menu + * link. + * - A site builder wants to create a menu item for a Views page that has not + * been created. In this case, there is no access to the route because it + * does not exist. + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree + * The menu link tree to manipulate. + * + * @return \Drupal\Core\Menu\MenuLinkTreeElement[] + * The manipulated menu link tree. + * + * @internal + * This menu tree manipulator is intended for use only in the context of + * MenuForm because the user permissions to administer links is already + * checked. Don't use this manipulator in other places. + * + * @see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess() + * @see \Drupal\menu_ui\MenuForm + */ + public function checkAccess(array $tree): array { + foreach ($tree as $element) { + $element->access = AccessResult::allowed(); + if ($element->subtree) { + $element->subtree = $this->checkAccess($element->subtree); + } + } + return $tree; + } + +} diff --git a/core/modules/menu_ui/src/MenuForm.php b/core/modules/menu_ui/src/MenuForm.php index 9d90ac5a607d90f27b5776b722ed9f4feb60fefb..9b4561f4da01c4949495e9a63919a6d4e6cc35a2 100644 --- a/core/modules/menu_ui/src/MenuForm.php +++ b/core/modules/menu_ui/src/MenuForm.php @@ -231,7 +231,13 @@ protected function buildOverviewForm(array &$form, FormStateInterface $form_stat // We indicate that a menu administrator is running the menu access check. $this->getRequest()->attributes->set('_menu_admin', TRUE); $manipulators = [ - ['callable' => 'menu.default_tree_manipulators:checkAccess'], + // Use a dedicated menu tree access check manipulator as users editing + // this form, granted with 'administer menu' permission, should be able to + // access menu links with inaccessible routes. The default menu tree + // manipulator only allows the access to menu links with accessible routes. + // @see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess() + // @see \Drupal\menu_ui\Menu\MenuUiMenuTreeManipulators::checkAccess() + ['callable' => 'menu_ui.menu_tree_manipulators:checkAccess'], ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'], ]; $tree = $this->menuTree->transform($tree, $manipulators); diff --git a/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php b/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php index 97b31371cb758f37db2df3965bb675e2e3371e93..c7f653098abfc4ec538c448be052ffd998b9a4d3 100644 --- a/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php +++ b/core/modules/menu_ui/tests/src/Functional/MenuUiTest.php @@ -727,10 +727,15 @@ public function testUnpublishedNodeMenuItem() { $this->drupalLogout(); $this->drupalLogin($this->adminUser); $this->drupalGet('admin/structure/menu/manage/' . $item->getMenuName()); - $this->assertSession()->pageTextNotContains($item->getTitle()); + $this->assertSession()->linkExists($item->getTitle()); // The cache contexts associated with the (in)accessible menu links are // bubbled. See DefaultMenuLinkTreeManipulators::menuLinkCheckAccess(). $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions'); + + // The menu link admin is able to administer the link but cannot access the + // route as is not granted with 'bypass node access' permission. + $this->clickLink($item->getTitle()); + $this->assertSession()->statusCodeEquals(403); } /** @@ -1229,4 +1234,47 @@ public function testMenuUiWithPendingRevisions() { $this->assertSession()->elementNotExists('xpath', '//div[contains(@class, "messages--error")]'); } + /** + * Tests the user login/logout links. + */ + public function testUserLoginUserLogoutLinks() { + MenuLinkContent::create([ + 'menu' => 'tools', + 'link' => [ + 'uri' => 'internal:/user/login', + ], + 'title' => 'Login', + ])->save(); + MenuLinkContent::create([ + 'menu' => 'tools', + 'link' => [ + 'uri' => 'internal:/user/logout', + ], + 'title' => 'Logout', + ])->save(); + + $assert = $this->assertSession(); + + $block = $this->drupalPlaceBlock('system_menu_block:tools'); + $this->drupalGet('<front>'); + $assert->linkExists('Login'); + $assert->linkNotExists('Logout'); + + $this->drupalLogin($this->createUser()); + $this->drupalGet('<front>'); + $assert->linkNotExists('Login'); + $assert->linkExists('Logout'); + + // Delete the block, we're now checking the Menu UI form. + $block->delete(); + + $this->drupalLogin($this->createUser(['administer menu'])); + $this->drupalGet('admin/structure/menu/manage/tools'); + + $assert->linkExists('Logout'); + // Check that the login link is accessible even the route is not. + $this->assertFalse(Url::fromRoute('user.login')->access($this->loggedInUser)); + $assert->linkExists('Login'); + } + } diff --git a/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php index ed8d0863554b06772ab855388520d242879790da..05a1bc4e1a0e9c2b30dffc183fc304fc9f2705d9 100644 --- a/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php +++ b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php @@ -280,12 +280,10 @@ public function testUserAccountMenu() { $this->enableEndpoint(TRUE); $expected_cacheability = new CacheableMetadata(); $expected_cacheability->addCacheContexts([ - 'user.permissions', 'user.roles:authenticated', ]); $expected_cacheability->addCacheTags([ 'config:system.menu.account', - 'config:user.role.anonymous', 'http_response', ]); $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/account/linkset')); diff --git a/core/modules/system/tests/src/Functional/System/AdminTest.php b/core/modules/system/tests/src/Functional/System/AdminTest.php index 8f72a98ec74065b550f757b431f244c19aec068f..cf65b1ac2731c517346836fd60cff4f4b9c87988 100644 --- a/core/modules/system/tests/src/Functional/System/AdminTest.php +++ b/core/modules/system/tests/src/Functional/System/AdminTest.php @@ -65,7 +65,14 @@ public function testAdminPages() { // Verify that all visible, top-level administration links are listed on // the main administration page. - foreach ($this->getTopLevelMenuLinks() as $item) { + foreach ($this->getTopLevelMenuLinks() as $element) { + $item = $element->link; + if (!$element->access->isAllowed()) { + // If the link is not accessible, it should not be rendered. + // @see \Drupal\Core\Menu\MenuLinkTree::buildItems(). + $this->assertSession()->linkNotExists($item->getTitle()); + continue; + } $this->assertSession()->linkExists($item->getTitle()); $this->assertSession()->linkByHrefExists($item->getUrlObject()->toString()); // The description should appear below the link. @@ -124,7 +131,7 @@ public function testAdminPages() { /** * Returns all top level menu links. * - * @return \Drupal\Core\Menu\MenuLinkInterface[] + * @return \Drupal\Core\Menu\MenuLinkTreeElement[] */ protected function getTopLevelMenuLinks() { $menu_tree = \Drupal::menuTree(); @@ -137,15 +144,7 @@ protected function getTopLevelMenuLinks() { ['callable' => 'menu.default_tree_manipulators:checkAccess'], ['callable' => 'menu.default_tree_manipulators:flatten'], ]; - $tree = $menu_tree->transform($tree, $manipulators); - - // Transform the tree to a list of menu links. - $menu_links = []; - foreach ($tree as $element) { - $menu_links[] = $element->link; - } - - return $menu_links; + return $menu_tree->transform($tree, $manipulators); } /** diff --git a/core/modules/views_ui/tests/src/Functional/DisplayPathTest.php b/core/modules/views_ui/tests/src/Functional/DisplayPathTest.php index 07d0178ff73fe29558a519105fd4ec63850b336a..f5c5c5d40b88904912878cd74da51d3d76d7e055 100644 --- a/core/modules/views_ui/tests/src/Functional/DisplayPathTest.php +++ b/core/modules/views_ui/tests/src/Functional/DisplayPathTest.php @@ -217,6 +217,7 @@ public function testDefaultMenuTabRegression() { 'administer menu', 'link to any page', 'access toolbar', + 'access administration pages', ]); $this->drupalLogin($admin_user); diff --git a/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php b/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php index c36c37fea913c345a8ea53db0751f4faee2bad81..43ad9d4784c47af68af282e207b6865b74847471 100644 --- a/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php +++ b/core/tests/Drupal/KernelTests/Core/Menu/MenuLinkTreeTest.php @@ -2,8 +2,11 @@ namespace Drupal\KernelTests\Core\Menu; +use Drupal\Core\Menu\InaccessibleMenuLink; use Drupal\Core\Menu\MenuLinkTreeElement; use Drupal\Core\Menu\MenuTreeParameters; +use Drupal\Core\Session\AnonymousUserSession; +use Drupal\Core\Session\UserSession; use Drupal\KernelTests\KernelTestBase; use Drupal\Tests\Core\Menu\MenuLinkMock; @@ -133,4 +136,42 @@ public function testCreateLinksInMenu() { $this->assertEquals(3, $height); } + /** + * Tests user/login and user/logout links. + */ + public function testUserLoginAndUserLogoutLinks() { + $account_switcher = $this->container->get('account_switcher'); + + $login_menu_link = MenuLinkMock::create(['id' => 'user_login_example', 'route_name' => 'user.login']); + $logout_menu_link = MenuLinkMock::create(['id' => 'user_logout_example', 'route_name' => 'user.logout']); + + $this->menuLinkManager->addDefinition($login_menu_link->getPluginId(), $login_menu_link->getPluginDefinition()); + $this->menuLinkManager->addDefinition($logout_menu_link->getPluginId(), $logout_menu_link->getPluginDefinition()); + + // Returns the accessible links from transformed 'mock' menu tree. + $get_accessible_links = function () { + $parameters = new MenuTreeParameters(); + $manipulators = [ + ['callable' => 'menu.default_tree_manipulators:checkAccess'], + ]; + + $tree = $this->linkTree->load('mock', $parameters); + $this->linkTree->transform($tree, $manipulators); + + return array_keys( + array_filter($tree, function (MenuLinkTreeElement $element) { + return !$element->link instanceof InaccessibleMenuLink; + }) + ); + }; + + // Check that anonymous can only access the login link. + $account_switcher->switchTo(new AnonymousUserSession()); + $this->assertSame(['user_login_example'], $get_accessible_links()); + + // Ensure that also user 1 does not see the login link. + $account_switcher->switchTo(new UserSession(['uid' => 1])); + $this->assertSame(['user_logout_example'], $get_accessible_links()); + } + } diff --git a/core/tests/Drupal/Tests/Core/Menu/DefaultMenuLinkTreeManipulatorsTest.php b/core/tests/Drupal/Tests/Core/Menu/DefaultMenuLinkTreeManipulatorsTest.php index 896699b805176222ab6c0d57e3af1e439661df2e..d24e0b7eaf39deb409c26dcf3d7dead5e8256883 100644 --- a/core/tests/Drupal/Tests/Core/Menu/DefaultMenuLinkTreeManipulatorsTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/DefaultMenuLinkTreeManipulatorsTest.php @@ -207,15 +207,12 @@ public function testCheckAccess() { $this->cacheContextManager->assertValidTokens(['user'])->shouldBeCalled()->willReturn(TRUE); $this->originalTree[8]->access = AccessResult::allowed()->cachePerUser(); - // Since \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::checkAccess() - // allows access to any link if the user has the 'link to any page' - // permission, *every* single access result is varied by permissions. $tree = $this->defaultMenuTreeManipulators->checkAccess($this->originalTree); // Menu link 1: route without parameters, access forbidden, but at level 0, // hence kept. $element = $tree[1]; - $this->assertEquals(AccessResult::forbidden()->cachePerPermissions(), $element->access); + $this->assertEquals(AccessResult::forbidden(), $element->access); $this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); // Menu link 2: route with parameters, access granted. $element = $tree[2]; @@ -223,21 +220,19 @@ public function testCheckAccess() { $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); // Menu link 3: route with parameters, AccessResult::neutral(), top-level // inaccessible link, hence kept for its cacheability metadata. - // Note that the permissions cache context is added automatically, because - // we always check the "link to any page" permission. $element = $tree[2]->subtree[3]; - $this->assertEquals(AccessResult::neutral()->cachePerPermissions(), $element->access); + $this->assertEquals(AccessResult::neutral(), $element->access); $this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); // Menu link 4: child of menu link 3, which was AccessResult::neutral(), // hence menu link 3's subtree is removed, of which this menu link is one. $this->assertArrayNotHasKey(4, $tree[2]->subtree[3]->subtree); // Menu link 5: no route name, treated as external, hence access granted. $element = $tree[5]; - $this->assertEquals(AccessResult::allowed()->cachePerPermissions(), $element->access); + $this->assertEquals(AccessResult::allowed(), $element->access); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); // Menu link 6: external URL, hence access granted. $element = $tree[6]; - $this->assertEquals(AccessResult::allowed()->cachePerPermissions(), $element->access); + $this->assertEquals(AccessResult::allowed(), $element->access); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); // Menu link 7: 'access' already set: AccessResult::neutral(), top-level // inaccessible link, hence kept for its cacheability metadata. @@ -247,31 +242,31 @@ public function testCheckAccess() { $element = $tree[5]->subtree[7]; $this->assertEquals(AccessResult::neutral(), $element->access); $this->assertInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); - // Menu link 8: 'access' already set, note that 'per permissions' caching - // is not added. + // Menu link 8: 'access' already set. $element = $tree[8]; $this->assertEquals(AccessResult::allowed()->cachePerUser(), $element->access); $this->assertNotInstanceOf('\Drupal\Core\Menu\InaccessibleMenuLink', $element->link); } /** - * Tests checkAccess() tree manipulator with 'link to any page' permission. + * Tests checkAccess() tree manipulator. * * @covers ::checkAccess * @covers ::menuLinkCheckAccess */ - public function testCheckAccessWithLinkToAnyPagePermission() { + public function testCheckAccessTreeManipulator(): void { $this->mockTree(); - $this->currentUser->expects($this->exactly(9)) - ->method('hasPermission') - ->with('link to any page') - ->willReturn(TRUE); + // There are 9 checks but one is on an external link, so the route access + // checker should be called only 8 times. + // @see \Drupal\Core\Menu\DefaultMenuLinkTreeManipulators::menuLinkCheckAccess() + $this->accessManager->expects($this->exactly(8)) + ->method('checkNamedRoute') + ->willReturn(AccessResult::allowed()); $this->mockTree(); - $this->cacheContextManager->assertValidTokens(['user.permissions'])->shouldBeCalled()->willReturn(TRUE); $this->defaultMenuTreeManipulators->checkAccess($this->originalTree); - $expected_access_result = AccessResult::allowed()->cachePerPermissions(); + $expected_access_result = AccessResult::allowed(); $this->assertEquals($expected_access_result, $this->originalTree[1]->access); $this->assertEquals($expected_access_result, $this->originalTree[2]->access); $this->assertEquals($expected_access_result, $this->originalTree[2]->subtree[3]->access); @@ -366,8 +361,8 @@ public function testCheckNodeAccess() { $this->assertEquals($node_access_result, $tree[1]->access); $this->assertEquals($node_access_result, $tree[2]->access); $this->assertEquals(AccessResult::neutral(), $tree[2]->subtree[3]->access); - $this->assertEquals(AccessResult::allowed()->cachePerPermissions(), $tree[5]->access); - $this->assertEquals(AccessResult::neutral()->cachePerPermissions(), $tree[5]->subtree[6]->access); + $this->assertEquals(AccessResult::allowed(), $tree[5]->access); + $this->assertEquals(AccessResult::neutral(), $tree[5]->subtree[6]->access); } /**