I've done this just recently by implementing a single menu that shows different links depending on the context (in your case the context is a portal). The one menu will have all the links for all portals, and is set to rebuild itself on each context containing the relevant links for that context. You can do this as follows.
- Implement hook_node_access_records() and hook_node_grants() to create viewing access records on the nodes that only allows for them to be viewed on the portal to which they belong.
- Alter the canonical route for nodes to provide a custom access callback. This access callback will include a check on whether the user has viewing access to the node. This will ensure that nodes only show up in the menu when on the portal to which they belong.
- Create a custom cache context for your portal, so that the menu can be set to vary by portal.
- Set your menu block to have the portal cache context, so that the menu is rebuilt for each portal, showing the correct menu items for the given portal.
1) Implement node grants
Node grants involve two hooks, hook_node_access_records() and hook_node_grants().
First, hook_node_access_records(). This hook is called upon node save. In it you perform calculations to determine upon which portals the node will be accessible. For your setup, you will set a record for each portal the node can be accessed at. Example:
function hook_node_access_records(NodeInterface $node) {
$grants = [];
// A grant needs to be added for each portal on which the node should be accessible.
$grants[] = [
// This will always be the same.
'realm' => 'portal',
// This will be the identifier of the portal.
'gid' => 'portal_1,
'grant_view' => 1
'grant_update' => 0,
'grant_delete' => 0,
];
return $grants;
}
How you get the GID (the portal ID) of the node will depend on how your system is set up. It sounds like your portals are set up using taxonomy terms. If that's the case, in hook_node_access_records() you can query that field to get the values from the taxonomy field and set your grants accordingly.
Next is hook_node_grants(). This hook is called anytime a node is accessed. This check will check if the user is on a portal, and add a grant for the current portal.
function hook_node_grants(AccountInterface $account, $op) {
if ($op == 'view') {
if ($portal = some_function_that_gets_the_current_portal_id()) {
$grants = [];
$grants['portal'] = $portal;
return $grants;
}
}
}
This code will return a grant for the current portal. If the node being accessed also belongs to the current portal (by matching a grant set in hook_node_access_records()), viewing permission will be shown.
2) Alter canonical node paths with a custom access callback.
With the above two hooks implemented, viewing access permissions for the node have been taken care of. However, the route is still accessible, as access permissions on the route only check if the user has 'access content' permission, and doesn't include a check on whether user has permission to access the node the route displays. When building menus, route permissions are checked, not access permissions on the node. So the next permission is to override the route, and provide a custom access callback that also checks whether the user has viewing access on the node. This way, due to the node grants set in steps one and two, when the menu is rebuilt, it will only add items to the menu if the user is on the portal the items belong to.
To do this, first an event subscriber is created to alter the routes:
namespace Drupal\my_module\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Listens to the dynamic route events.
*/
class RouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('entity.node.canonical')) {
$route->setRequirement('_custom_access', 'Drupal\my_module\Access\PortalNode::access');
}
}
}
The above class will alter the entity.node.canonical
route, giving it a custom access callback. The next thing to do is create Drupal\my_module\Access\PortalNode::access()
and include the check on viewing permissions for the node. This class will look like:
namespace Drupal\example\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\node\NodeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class PortalNode implements ContainerInjectionInterface {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected $currentUser;
/**
* Constructs a PortalNode instance.
*
* @param \Drupal\Core\Session\AccountProxyInterface $currentUser
* The current user.
*/
public function __construct(
AccountProxyInterface $currentUser
) {
$this->currentUser = $currentUser;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user')
);
}
/**
* Custom access callback for node canonical routes.
*
* @param \Drupal\node\NodeInterface
* The node whose canonical route is being accessed.
*
* @return \Drupal\Core\Access\AccessResult
* An access result that either provides or denies access.
*/
public function access(NodeInterface $node) {
// Allow access if the user has 'access content' permission (the default)
// as well as viewing permission on the given node.
return AccessResult::allowedIf(
$this->currentUser->hasPermission('access content')
&&
$node->access('view')
);
}
}
Finally, the event subscriber needs to be registered in my_module.service.yml so it is picked up, and the route is altered:
services:
my_module.route_subscriber:
class: Drupal\my_module\Routing\RouteSubscriber
tags:
- { name: event_subscriber }
3) Add a custom cache context for portals.
As your menu will need to differ by portal, it must have a cache context set for portal. There may already be a cache context you can use that matches the Drupal functionality behind your portals. You can check existing cache contexts here: https://www.drupal.org/docs/8/api/cache-api/cache-contexts#core-contexts
If the cache context you need does not exist, you'll need to create a custom cache context. Here's an example: https://medium.com/@joshirohit100/cache-context-drupal-8-part-2-10088f67012e
4) Set the menu block to vary by the portal cache context
At this point everything is mostly set up, however, the first time the menu is built, that version of the menu will be shown the same on all portals, as it has not been set to vary by portal cache context. Your system needs to have the menu block vary by portal - ie. variations of the menu must exist for each portal, since each portal will have a different list of links in the menu.
To do this, you'll implement hook_block_alter() in order to add a custom cache context on the menu block. You'll need to get the ID of the menu from your system. Let's assume it's "portal-administration".
/**
* Implements hook_block_alter().
*/
function my_module_block_alter(array &$definitions) {
$definitions['system_menu_block:portal-administration']['class'] = 'Drupal\my_module\Plugin\Block\PortalAdministrationMenuBlock';
}
Finally, the class defined in hook_block_alter() is created. It's extends the system default menu class, and adds the portal cache context to the menu block:
namespace Drupal\my_module\Plugin\Block;
use Drupal\system\Plugin\Block\SystemMenuBlock;
class PortalAdministrationMenuBlock extends SystemMenuBlock {
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
$cache_contexts = parent::getCacheContexts();
$cache_contexts[] = 'portal';
return $cache_contexts;
}
}