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 reacheshook_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 iddefault. 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_accessswitches 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.