Skip to content

Access

Resolve per-site roles in code, check a permission on one site, extend the site gate, and test access the way a request decides it.

Platform roles, per-site roles and the permissions a per-site role never grants are explained on Roles and permissions.

A request passes three layers:

flowchart LR
  req([Request]) --> gate{"Site gate<br/>SitePluginBase::access()"}
  gate -- forbidden --> nf[404]
  gate --> policy["SitesAccessPolicy<br/>adds per-site roles"]
  policy --> route{Route access}
  route --> entity{"SiteEntityAccess<br/>hook_entity_access"}
  entity -- "forbidden, not this site's" --> nf
  entity -- forbidden --> denied[403]

Per-site roles

sites.site_role_manager (Drupal\sites\SiteRoleManagerInterface) reads and writes the role assignments, one row per user and site. An assignment counts whether or not the user is a member of the site.

$roles = \Drupal::service('sites.site_role_manager');

$roles->getAssignableRoleIds($site);             // the roles the site offers
$roles->getRoleIdsForUserOnSite($account, 'acme'); // as assigned
$roles->getEffectiveRoleIds($account, $site);    // the roles in force
$roles->sitePermissions($role);                  // what a role stands for on a site

getEffectiveRoleIds() returns the assigned roles the site offers, plus getInheritedRoleIds(): a sub-site with Run by the main site's people on (main_site_participation, Delegate a site) takes the roles in force on its main site that both sites offer, one level deep. getRoleSourceSite($site) returns that main site; cache an answer with its plugin tag too.

sitePermissions() drops SitesPermissions::NEUTRALIZED_FROM_SITE_ROLES. An administrator role stands for every permission core does not mark "restrict access", plus those listed in sites.settings:site_admin_permissions.

setRolesForUserOnSite() stores what it is given; code acting for a person assigns through sites.members (SiteMembersInterface::applyRoles()), which keeps to getDelegableRoleIds().

The access policy

SitesAccessPolicy (service sites.access_policy) changes the drupal scope of the calculated permissions, varying by the site, sites_context_site and user.sites cache contexts:

  • On a site's host: adds what the roles in force there stand for, and removes bypass node access, a global administrator's included, so node access reaches hook_entity_access.
  • On the hub: adds the roles of the site the request points at (sites.pointed_site->forAccess()): the canonical site of the route's content entity, else the route's upcast {site}, else ?site_id; without one, the roles assigned under the reserved site id default. Per-site roles held anywhere also add the administration theme, navigation, administration pages and the content and media overviews.

Check a permission on a site

On the hub, $account->hasPermission() can carry a pointed site's roles. To ask about one site, use sites.capabilities (SiteCapabilitiesInterface): it counts a real global role or a role in force on that site.

$capabilities = \Drupal::service('sites.capabilities');

$capabilities->hasSiteCapability($account, 'acme', ['manage site settings']);
$access = $capabilities->sitePermissionAccess($account, $site, ['manage site settings'], NULL);
$sites = $capabilities->permittedSites($account, ['manage site settings']);
$sites = $capabilities->permittedSitesOf($node, $account, ['override site content']);

A real global administer sites stands in for every per-site permission except view site access reports, which takes administer sites configuration. A route with a {site} parameter checks the same with a requirement:

my_module.site_report:
  path: '/admin/sites/{site}/my-report'
  requirements:
    _sites_permission: 'manage site settings'
  options:
    parameters:
      site:
        type: site

Cache a boolean answer with SiteAccessCacheability::grant($account) (Caching).

The site gate

On every main request, SitesKernelRequestSubscriber asks the active site's access(), and a forbidden result answers 404. SitePluginBase::access() refuses a disabled site to accounts without access disabled sites or administer sites, then maps applies(): TRUE allows, FALSE forbids, NULL leaves the decision to the route. ContentSite::applies() forbids the page of another site's content, and of content on no site whose bundle the site takes.

hook_site_access_alter() and hook_site_SITE_ID_access_alter() change the result. A forbidden result wins over any orIf():

#[Hook('site_access_alter')]
public function siteAccessAlter(AccessResultInterface &$access, AccountInterface $account, array &$context): void {
  // The archive answers on the hub only.
  if (!$context['site']->isHub() && $context['route_match']->getRouteName() === 'my_module.archive') {
    $access = $access->orIf(AccessResult::forbidden()->addCacheContexts(['route']));
  }
}

On a site's host, SiteAccessDeniedSubscriber turns a later 403 into 404 for a routed entity of another site and for a refused site section.

Entity access

sites.entity_access (SiteEntityAccessInterface) decides entity operations; the suite's hook_entity_access and hook_entity_create_access implementations forward to it. Ask it directly for the reason of a refusal. access() holds:

  • View isolation: content is visible on its sites' hosts, and on the hub to its sites' people and administer sites (sites.settings:enforce_view_access switches it off).
  • Write gate: update and delete belong to the canonical site's people, on its host or on the hub. Other member sites change content through overrides.
  • Hub grant: on the hub, the entity type's access handler runs again as though the roles a participant holds on one of the entity's sites were global. A sub-site run by its main site's people counts the main site's participants.
  • Users: editor and edited user must share a site.

scopedCreateAccess() and contentCreateAccess() are the create gates.

JSON:API and REST

API requests pass the same layers: another site's item answers 404 on a site's host, and 403 on its own host to an account without a role there. Collections leave out what entity access refuses and follow the site's listing filter. A POST on a site's host belongs to that site, and API requests work on the canonical version. The suite's bookkeeping entity types are internal: no API exposes them.

Test per-site roles

A per-site role's permissions exist only in a request's calculated permissions: test them in functional tests on SitesFunctionalTestBase, whose drupalGetOnHost() spoofs a site's host. Give the actors only the per-site role; administer sites passes every gate and proves nothing.

namespace Drupal\Tests\my_module\Functional;

use Drupal\Tests\sites\Functional\SitesFunctionalTestBase;
use Drupal\user\Entity\Role;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

#[RunTestsInSeparateProcesses]
#[Group('my_module')]
final class EditorAccessTest extends SitesFunctionalTestBase {

  protected static $modules = ['node', 'sites', 'sites_content'];

  public function testEditorOfOneSite(): void {
    $this->drupalCreateContentType(['type' => 'page']);
    Role::create(['id' => 'editor', 'label' => 'Editor'])
      ->grantPermission('access content')
      ->grantPermission('edit any page content')
      ->save();
    $acme = $this->createSiteOfferingRoles('acme', ['editor']);
    $globex = $this->createSiteOfferingRoles('globex', ['editor']);
    $own = $this->drupalCreateNode(['type' => 'page']);
    $this->assignContentToSite($own, $acme, TRUE);
    $foreign = $this->drupalCreateNode(['type' => 'page']);
    $this->assignContentToSite($foreign, $globex, TRUE);

    $this->drupalLogin($this->memberWithRole('acme', 'editor'));
    $this->drupalGetOnHost('acme.example.com', 'node/' . $own->id() . '/edit');
    $this->assertSession()->statusCodeEquals(200);
    // Another site's content does not exist on acme's host ...
    $this->drupalGetOnHost('acme.example.com', 'node/' . $foreign->id() . '/edit');
    $this->assertSession()->statusCodeEquals(404);
    // ... and stays closed on the hub.
    $this->drupalGetOnHost(NULL, 'node/' . $foreign->id() . '/edit');
    $this->assertSession()->statusCodeEquals(403);
  }

}

More helpers: Testing. SubSiteInheritedRolesTest in sites tests inherited roles.