Skip to content

Scope Restriction Extension Points

Beta - APIs may change

Advanced Admin Permissions is in beta. The interfaces, pool names and di.xml arguments described here may change in backwards-incompatible ways before the general release. Customizations built against them may need updating between beta versions.

Magento has no single mechanism that scopes every entity, and writing per-entity code for each one would neither scale nor cover custom modules. Advanced Admin Permissions therefore enforces its website and store view restriction through three ordered pools of strategies, each resolved from di.xml. Most entities are covered by the generic strategies because they follow the conventions Magento itself uses. For an entity that scopes itself some other way, add a strategy of your own.

All three interfaces live in the Hyva\AdvancedAdminPermissionsScope\Api namespace and receive an AllowedScopes value object describing the websites and store views the current admin user holds.

The Three Pools and When Each Is Consulted

Interface Question it answers Pool it is registered in
ScopeCollectionFilterInterface How do I narrow this collection to the admin's scopes? ScopeCollectionFilterPool, argument filters
ScopeEntityGuardInterface Is this loaded record outside the admin's scopes? EntityScopeGuard, argument guards
ScopeWriteRuleInterface Does this write reach into a scope the admin does not hold? EntityScopeWriteGuard, argument writeRules

In every pool the strategies are consulted in the order they are configured, and the first one that recognizes the entity decides. A strategy for a specific entity family must therefore be registered before the generic ones.

The order comes from the sortOrder attribute of each item, sorted ascending by Magento's SortItems. The strategies Advanced Admin Permissions ships have no sortOrder, which counts as 0, so give yours a negative sortOrder to run it before them. A positive value puts your strategy after the shipped ones.

Narrowing a Collection with ScopeCollectionFilterInterface

ScopeCollectionFilterInterface narrows a database collection to the scopes a restricted admin may see. This is what makes grids, dropdowns and the id lists behind mass actions show only the admin's own data.

The interface has a single method. Return true when your strategy recognized and handled the collection, so no further strategy is consulted, and false to pass it on:

use Hyva\AdvancedAdminPermissionsScope\Model\AllowedScopes;
use Magento\Framework\Data\Collection\AbstractDb;

public function apply(AbstractDb $collection, AllowedScopes $scopes): bool;

The AbstractDb class here is Magento\Framework\Data\Collection\AbstractDb. Importing the resource model AbstractDb instead causes an incompatible-declaration fatal error.

Two rules for an implementation: it must not assume it is called only once per collection, and it must leave collections it does not understand completely untouched.

Register your filter ahead of the generic scope_column and link_table strategies, which recognize most entities and would otherwise claim yours first:

app/code/Vendor/Module/etc/di.xml
<type name="Hyva\AdvancedAdminPermissionsScope\Model\CollectionFilter\ScopeCollectionFilterPool">
    <arguments>
        <argument name="filters" xsi:type="array">
            <!-- Shipped strategies have no sortOrder (= 0); a negative sortOrder runs yours before them. -->
            <item name="vendor_subscription" xsi:type="object" sortOrder="-10">Vendor\Module\Model\ScopeFilter\SubscriptionScopeFilter</item>
        </argument>
    </arguments>
</type>

The strategies Advanced Admin Permissions ships, in order, are catalog_product, design_config, review, scope_column and link_table. The two specific ones earn their place for reasons worth knowing if you write your own:

  • design_config runs before the generic column filter because each row in the design configuration grid is a scope rather than a record belonging to one, so the grid is narrowed to the scopes the admin may open instead of to the data they may see.
  • review runs before the generic link table filter because review_store looks like any other link table, but Magento appends store view 0 to every review it saves. Read as "all store views", that matched every review in the installation.

Guarding a Loaded Entity with ScopeEntityGuardInterface

Filtering grids is not enough on its own: an admin who knows an id can open the edit page of a record that never appeared in their grid. ScopeEntityGuardInterface closes that path by deciding whether a single loaded entity belongs to a scope the current admin may not reach.

Return null when your guard cannot tell, so the next guard gets a turn. Returning false stops the chain and declares the entity in scope:

use Hyva\AdvancedAdminPermissionsScope\Model\AllowedScopes;
use Magento\Framework\DataObject;

public function isOutOfScope(DataObject $entity, AllowedScopes $scopes): ?bool;

An entity a guard reports as out of scope is blanked, so the admin reports it the way it reports a deleted record. The entity guards are consulted on saves as well, so a custom guard that returns true makes a save of that entity throw too.

Register a guard in the guards argument of EntityScopeGuard, again ahead of the generic strategies:

app/code/Vendor/Module/etc/di.xml
<type name="Hyva\AdvancedAdminPermissionsScope\Model\EntityGuard\EntityScopeGuard">
    <arguments>
        <argument name="guards" xsi:type="array">
            <!-- Shipped strategies have no sortOrder (= 0); a negative sortOrder runs yours before them. -->
            <item name="vendor_subscription" xsi:type="object" sortOrder="-10">Vendor\Module\Model\ScopeGuard\SubscriptionEntityGuard</item>
        </argument>
    </arguments>
</type>

Refusing a Write with ScopeWriteRuleInterface

ScopeWriteRuleInterface is the counterpart of the entity guard, and deliberately a separate decision rather than the same one reused. The read side asks "may the admin see this record", while the write side asks "does this write reach into a scope the admin does not hold". Those answers differ in two important ways:

  • Scope 0 is readable but not writable. Global data is inherited by every store view, so writing it changes what store views outside the admin's reach display.
  • A record spanning several scopes is readable as soon as one of them is allowed, but writable only when all of them are. A CMS page assigned to two store views is visible to an admin who holds one of them, yet saving it changes what the other one shows.

Return true to refuse the write, false when your rule has no objection, and null when your rule does not recognize the entity:

use Hyva\AdvancedAdminPermissionsScope\Model\AllowedScopes;
use Magento\Framework\DataObject;

public function isWriteDenied(DataObject $entity, AllowedScopes $scopes): ?bool;

Write rules are consulted last. A write is already refused if an entity guard reports the entity out of scope, or if the stored row belongs to a scope the admin does not hold. Returning false therefore does not allow a write the other checks refuse: it only means this rule has no objection.

A refused write raises an AuthorizationException. Register the rule in the writeRules argument of EntityScopeWriteGuard:

app/code/Vendor/Module/etc/di.xml
<type name="Hyva\AdvancedAdminPermissionsScope\Model\EntityGuard\EntityScopeWriteGuard">
    <arguments>
        <argument name="writeRules" xsi:type="array">
            <!-- Shipped strategies have no sortOrder (= 0); a negative sortOrder runs yours before them. -->
            <item name="vendor_subscription" xsi:type="object" sortOrder="-10">Vendor\Module\Model\ScopeWrite\SubscriptionWriteRule</item>
        </argument>
    </arguments>
</type>

An entity nobody recognizes is allowed through

Both the read and the write side act only on a verdict. No scope column, no link table and no strategy that recognizes the entity means no restriction at all. Scope restriction narrows what is reachable, it is not an allowlist of readable or writable tables. Keep pairing it with the ACL resources that gate the actions themselves.

Changing the Denied Resource List

The globally-scoped areas denied to a restricted admin are a di.xml argument, so your installation can draw its own line. The list is the deniedResources argument of DenyGlobalFeaturesPlugin.

Add a resource to deny it to every restricted admin, or override a shipped entry with xsi:type="null" to let it through again. An empty string item does not work for this, because the di.xml merge keeps the original value:

app/code/Vendor/Module/etc/di.xml
<type name="Hyva\AdvancedAdminPermissionsScope\Plugin\DenyGlobalFeaturesPlugin">
    <arguments>
        <argument name="deniedResources" xsi:type="array">
            <!-- Deny an area of your own to restricted admins -->
            <item name="vendor_global_settings" xsi:type="string">Vendor_Module::global_settings</item>
            <!-- Let Import History through again -->
            <item name="history" xsi:type="null"/>
        </argument>
    </arguments>
</type>

The item names of the shipped deniedResources entries are import, export, history, tax_import_export, store_management, tax, backup, backup_rollback, integrations, currency_rates, product_attributes, attribute_sets, customer_groups, order_statuses, indexer, indexer_mode and indexer_invalidate. Email templates are not on the list: they are deliberately reachable.

The question to ask of a candidate is not whether its table has a scope column, but whether someone responsible for one store view or a couple of websites has any business in it.

Entities That Must Never Be Filtered or Guarded

Two exclusion lists keep the application's own load-bearing tables out of the restriction, and both are di.xml arguments you can extend for an entity of yours that has the same problem:

  • excludedResources on EntityScopeGuard lists resource models the entity guards must never blank or refuse. It exempts both loads and saves, because the write guard honors it too. The store, website and store group models carry a store_id or website_id of their own, so blanking one would take the application's store structure away. The admin user, role and rule entities are how permissions are resolved in the first place. The list also covers core_config_data and the order sequence tables.
  • excludedTables on RestrictCollectionScopePlugin lists tables that must stay unfiltered: the store structure tables, core_config_data, and the permission tables. Filtering the store structure tables would both break the application and recurse through the plugin itself.

Configuration is the one place neither the read filter nor the entity guard can help, because the application reads configuration long before a restriction could be evaluated. ConfigScopeAccess is checked on the way into the controllers instead, with a second check in Magento\Config\Model\Config::save() and in the design configuration repository for anything that reaches them another way.