Modeling Custom Roles and Permissions Cleanly
Magento 2 governs every click in the backend through a hierarchical Admin ACL system: acl.xml declares the resource tree, roles under System, Permissions, User Roles bind administrators to exactly the resources they need, and isAllowed() checks in controllers, blocks and view models enforce those rights at runtime. Anyone who builds custom modules without declaring their own ACL resources exposes new admin functionality by default to every role that has access to the parent resource, a risk that often only surfaces during the next role audit. This article shows how to build the resource tree correctly, model custom ACL resources for custom modules, check permissions in controllers and view models, and avoid the most common pitfalls around acl.xml, system.xml bindings and config caching.
Table of Contents
- 1. What Admin ACL Actually Controls in Magento 2
- 2. The Resource Tree in acl.xml: Inheritance and sortOrder
- 3. Declaring Custom ACL Resources for a Custom Module
- 4. Roles and Users: How ACL Resources Work in the Roles Grid
- 5. Checking isAllowed() in Controller, Block and View Model
- 6. Overriding _isAllowed() in a Custom Admin Controller
- 7. system.xml and ACL: Binding Config Sections Correctly
- 8. ACL Caching and Reloading Permissions
- 9. Common Mistakes in Admin ACL Modeling
- 10. Summary
- 11. FAQ
1. What Admin ACL Actually Controls in Magento 2
The Admin ACL in Magento 2 is the central access control mechanism for the backend: it decides which menu item appears in the admin navigation, which controller action may be executed, and which configuration area under Stores, Configuration is even displayed. Unlike customer group permissions on the storefront, Admin ACL is fully hierarchical: every resource hangs off a parent node all the way up to the root Magento_Backend::admin. This tree structure is merged from the acl.xml files of every active module into a single resource tree.
For developers, this means: a custom module that ships a new admin page, an export feature, or an additional configuration area must anchor that functionality as its own ACL resource in the tree. If it does not, the new feature automatically inherits the permission of its nearest declared parent node, in the worst case directly Magento_Backend::admin. That makes the action visible and executable for every role with any administrative access at all, regardless of whether that was intended. A cleanly modeled Admin ACL concept is therefore not a nice-to-have, it is a baseline requirement for any custom module running in production.
2. The Resource Tree in acl.xml: Inheritance and sortOrder
Every module can attach its own nodes to the global resource tree under etc/acl.xml. The schema requires an <acl> root element with exactly one <resources> container, inside which arbitrarily nested <resource> elements can appear. Each resource has an id in the format Vendor_Module::identifier, a title that shows up as a label in the roles grid, and an optional sortOrder attribute that determines the order among sibling nodes. The convention of assigning sortOrder values in steps of ten has proven useful: it leaves room to insert further resources later without renumbering the entire tree.
Inheritance in the resource tree mainly affects the display in the roles grid: disabling a parent node in the tree selector automatically hides all its child nodes. At runtime, however, this does not automatically apply in reverse: a role that has been explicitly granted only a child resource does not thereby gain automatic access to the parent resource if that parent is checked separately via isAllowed(). Magento persists exactly the resource IDs marked in the tree at role-assignment time in the authorization_rule table, not implicitly the full inheritance path. This distinction between visual tree inheritance in the roles grid and actual permission checks at runtime is one of the most commonly misunderstood aspects of Admin ACL.
3. Declaring Custom ACL Resources for a Custom Module
For a custom module, a flat, clearly named resource tree is recommended: a root node for the module itself, below it one node each for dashboard, grid views, critical single actions such as delete or export, and a separate node for configuration. This granularity later makes it possible to grant a role read-only access to a grid, for example, while explicitly withholding the delete action, without having to create an entirely new role for it. The following acl.xml shows this structure for a module that manages redirects.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Mironsoft_SeoSuite::seosuite" title="SEO Suite" sortOrder="200">
<resource id="Mironsoft_SeoSuite::seosuite_dashboard" title="Dashboard" sortOrder="10"/>
<resource id="Mironsoft_SeoSuite::seosuite_redirects" title="Manage Redirects" sortOrder="20">
<resource id="Mironsoft_SeoSuite::seosuite_redirects_delete" title="Delete Redirects" sortOrder="10"/>
</resource>
<resource id="Mironsoft_SeoSuite::seosuite_config" title="Configuration" sortOrder="30"/>
</resource>
</resource>
</resources>
</acl>
</config>
Consistently following the Vendor_Module::identifier format for every single ID matters. A typo or a missing prefix does not cause a deploy error, it results in a resource that appears in the tree while the corresponding isAllowed() check in code never actually matches, because the referenced string does not exist. This class of bug usually only surfaces when a client reports that a restricted role still has access to a feature that was supposed to be locked down.
4. Roles and Users: How ACL Resources Work in the Roles Grid
The area under System, Permissions, User Roles is the interface through which administrators define ACL roles and assign the previously declared resource tree to roles as a checkbox tree. Every role has either the "All" option, which grants blanket access to the entire tree, or "Custom", where individual resources are checked in the tree. Internally, Magento creates an entry in authorization_role for each role, and a record in authorization_rule with role_id, resource_id and the permission allow for each assigned resource. Users themselves are assigned to a role in the User Roles area and thereby inherit exactly that role's resource set.
In practice, this means admin permissions are never defined directly on the user, they are always defined through the role. A common mistake in migration projects is to set users to "All" temporarily to move faster, and then to accidentally carry that overly generous role into production. Anyone modeling ACL roles cleanly instead defines several tiered roles from the start, for example a role with pure read access to reports, a role with write access to catalog data, and a narrow administrator role that is actually allowed to fully manage only the team's own custom modules.
5. Checking isAllowed() in Controller, Block and View Model
Through the injectable service Magento\Framework\AuthorizationInterface, virtually any point in the code can check whether the currently logged-in admin session has access to a given ACL resource. The method isAllowed(string $resourceId): bool evaluates the tree stored for the logged-in role in authorization_rule. This is particularly relevant when an action is reachable through a controller, but its button should only appear in the template under certain conditions, for example a delete button in a redirects overview.
<?php
declare(strict_types=1);
namespace Mironsoft\SeoSuite\ViewModel;
use Magento\Framework\Authorization;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* ViewModel to expose ACL-based visibility flags to templates.
*/
class RedirectPermissions implements ArgumentInterface
{
/**
* @param Authorization $authorization Magento authorization service for ACL checks
*/
public function __construct(
private readonly Authorization $authorization
) {
}
/**
* Checks whether the current admin user may delete redirects.
*
* @return bool
*/
public function canDeleteRedirects(): bool
{
return $this->authorization->isAllowed('Mironsoft_SeoSuite::seosuite_redirects_delete');
}
}
This kind of check is purely cosmetic: it hides UI elements, but it never replaces server-side enforcement. A user who calls the backend URL of the delete action directly would still be able to execute the action without additional protection in the controller, even if the button was invisible in the template. That is why every isAllowed() check in a template or view model must always go hand in hand with a corresponding check at the controller level, never as a substitute for it.
6. Overriding _isAllowed() in a Custom Admin Controller
Every backend controller that extends Magento\Backend\App\Action declares the default resource to check against, via the ADMIN_RESOURCE constant, which Magento verifies automatically before the execute() method runs. When this simple check is not enough, for example because an action should require both the specific delete resource and the parent grid resource, the protected _isAllowed() method is overridden and multiple isAllowed() calls are combined.
<?php
declare(strict_types=1);
namespace Mironsoft\SeoSuite\Controller\Adminhtml\Redirect;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\SeoSuite\Api\RedirectRepositoryInterface;
/**
* Deletes a single redirect entity from the admin grid.
*/
class Delete extends Action
{
/**
* ACL resource required to access this controller.
*/
public const ADMIN_RESOURCE = 'Mironsoft_SeoSuite::seosuite_redirects_delete';
/**
* @param Context $context Backend action context
* @param RedirectRepositoryInterface $redirectRepository Repository for redirect entities
*/
public function __construct(
Context $context,
private readonly RedirectRepositoryInterface $redirectRepository
) {
parent::__construct($context);
}
/**
* Executes the delete action after the ACL check in _isAllowed() has passed.
*
* @return ResultInterface
* @throws NoSuchEntityException
*/
public function execute(): ResultInterface
{
$id = (int) $this->getRequest()->getParam('id');
$this->redirectRepository->deleteById($id);
$result = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
return $result->setPath('*/*/');
}
/**
* Overrides the default ACL check to additionally require the parent
* "redirects" resource, not only the delete-specific leaf resource.
*
* @return bool
*/
protected function _isAllowed(): bool
{
return $this->_authorization->isAllowed(self::ADMIN_RESOURCE)
&& $this->_authorization->isAllowed('Mironsoft_SeoSuite::seosuite_redirects');
}
}
The dispatch mechanism in Magento\Backend\App\AbstractAction calls _isAllowed() automatically before every controller call, and returns a 403 response with a redirect to the login or denied page if it returns false, without execute() ever being reached. This central enforcement is the actual core of Admin ACL: it applies regardless of whether a frontend developer remembered to hide the delete button in the template.
7. system.xml and ACL: Binding Config Sections Correctly
Configuration areas under Stores, Configuration are declared through system.xml, and Admin ACL applies here too: every <section> can be bound to an ACL resource through a <resource> child element. If this element is missing, Magento falls back to the generic resource Magento_Config::config, the same resource that practically every role with access to any configuration area holds. For sensitive settings, such as API credentials or feature flags that affect pricing, a dedicated, narrower resource is almost always the right choice.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<tab id="mironsoft" translate="label" sortOrder="500">
<label>Mironsoft</label>
</tab>
<section id="mironsoft_seosuite" translate="label" type="text" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="1">
<label>SEO Suite</label>
<tab>mironsoft</tab>
<resource>Mironsoft_SeoSuite::seosuite_config</resource>
<group id="general" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>General</label>
<field id="enabled" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Enabled</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
</group>
</section>
</system>
</config>
The binding acts on two levels at once: it decides whether the section even appears in the left navigation tree of the configuration page, and it is also checked when configuration values are saved, in the Magento\Config\Model\Config save controller. A role without the matching admin permission either does not see the section at all, or receives an error message when trying to save values, even if it accesses the URL directly.
8. ACL Caching and Reloading Permissions
The resource tree assembled from all acl.xml files is cached, like most declarative XML configuration in Magento, under the config cache type. After any change to an acl.xml file, whether a new resource, a changed sortOrder, or a renamed title attribute, this cache segment must be flushed for the change to become visible in the roles grid. An important distinction: the actual role assignment stored in authorization_rule lives in the database and is completely independent of this cache, so a cache flush never accidentally clears existing role permissions.
# ACL resource tree is part of the "config" cache type
bin/magento cache:status | grep config
# Flush only the config cache after every acl.xml change
bin/magento cache:flush config
# Verify a role's persisted permissions directly in the database
bin/mysql -e "SELECT resource_id, permission FROM authorization_rule WHERE role_id = 2;"
In practice, a targeted bin/magento cache:flush config is often enough during development, a full cache:flush is only necessary if other XML configuration was changed in parallel. After a deployment through setup:upgrade, the cache is invalidated automatically anyway, so new Admin ACL resources from freshly rolled-out modules appear in the roles grid right after the first page load.
9. Common Mistakes in Admin ACL Modeling
By far the most common mistake is a controller action without its own entry in acl.xml. Since Magento silently falls back to the inherited default resource in this case, usually Magento_Backend::admin itself, the mistake does not show up as an exception, it shows up as a creeping security problem: any role with any administrative access at all can execute the action, regardless of the restriction that was actually intended. A second, related mistake concerns system.xml sections without a <resource> element, which remain bound to the far too broad generic configuration permission as a result.
The table below contrasts the most common pitfalls with the recommended Admin ACL patterns that avoid these problems from the start.
| Scenario | Wrong Approach | Recommended Admin ACL Pattern | Effect |
|---|---|---|---|
| New controller action | No own acl.xml entry | Own resource ID + set ADMIN_RESOURCE | Prevents access by every role |
| system.xml section | No <resource> element | Bind an explicit dedicated resource | Prevents access through the generic config role |
| Resource ID format | No Vendor_Module prefix | Always use Vendor_Module::identifier | Avoids silent isAllowed() mismatches |
| Renaming a resource ID | Without migrating roles | Setup patch to reassign roles | Prevents loss of permissions for existing roles |
| Cache after acl.xml change | No cache flush | bin/magento cache:flush config | New resource visible in the roles grid immediately |
Another, more subtle mistake arises when a resource ID is renamed during a refactor without migrating the existing role entries in authorization_rule. The old ID then remains an orphaned record in the database, while the new resource is not actively assigned to a single role, with the result that even administrator roles suddenly lose access to the corresponding feature after deployment. A setup patch that keeps old and new resource IDs in sync is the only reliable solution here.
10. Summary
Admin ACL in Magento 2 is a hierarchical resource tree assembled from every active module's acl.xml file. Every custom module must declare its own resources instead of relying on an inherited default permission, otherwise a new feature opens up by default to any role with access to the parent resource. The actual enforcement does not happen in the roles grid, it happens at runtime through isAllowed() on the injectable AuthorizationInterface service, through ADMIN_RESOURCE and _isAllowed() in backend controllers, and through the <resource> element in system.xml sections.
Avoiding the three most common mistakes covers most of the ground: give every controller action its own ACL resource, bind every sensitive config section explicitly instead of letting it fall back to the generic Magento_Config::config resource, and flush the config cache after every acl.xml change. For a sustainable role structure it also pays to define several tiered roles from the start instead of a single broad administrator role.
Admin ACL in Magento 2, the essentials at a glance
Resource tree
Declare your own resources under Vendor_Module::identifier in acl.xml, never rely on the inherited default permission.
Runtime enforcement
ADMIN_RESOURCE and _isAllowed() in the controller are the real safeguard, UI checks via isAllowed() are cosmetic only.
system.xml binding
Every sensitive config section gets its own <resource> element instead of the generic config resource.
Cache & migration
Run cache:flush config after every acl.xml change, migrate existing roles via a setup patch whenever a resource ID is renamed.
11. FAQ: Admin ACL in Magento 2
1What exactly is Admin ACL in Magento 2?
2What happens without a custom ACL resource?
3What is sortOrder used for in acl.xml?
4Is isAllowed() in the template enough protection?
5When do I override _isAllowed()?
6How do I bind system.xml to an ACL resource?
7Which cache needs flushing?
8What is the most common ACL mistake?
9What happens when I rename a resource ID?
10Difference from customer group permissions?
Mironsoft
Magento 2 backend security, ACL audits and role concepts
Admin ACL that actually protects, not just administers?
We review existing acl.xml structures, model missing ACL resources for custom modules, and build tiered role concepts that bind admin permissions exactly to actual need.
ACL Audit
Complete review of every controller, system.xml section and resource ID for missing bindings
Role Modeling
Tiered admin roles built around real responsibilities instead of blanket All access
Security Review
Custom module audits for _isAllowed() overrides and consistent Vendor_Module formats