ACL and Admin Access
An admin controller is protected by one constant: the ACL resource it names in acl.xml and that a role must hold.
public const ADMIN_RESOURCE = 'Vendor_Module::resource_id';An ACL resource (access control list resource) is a named permission, written as Vendor_Module::identifier. You declare it in your module's etc/acl.xml. Magento merges every module's acl.xml into one tree rooted at Magento_Backend::admin, and that tree is the checkbox list under System > Permissions > User Roles. A role is a set of checked resources. An admin user has one role.
How a controller is protected
Every admin controller extending Magento\Backend\App\Action carries a constant ADMIN_RESOURCE. Before execute() runs, the base class calls _isAllowed(), which asks Magento\Framework\AuthorizationInterface::isAllowed($resource) whether the signed-in user's role holds that resource. If not, the request gets the "Sorry, you need permissions" page instead of your action.
A menu item is a separate decision. In etc/adminhtml/menu.xml, the resource attribute decides whether the item is drawn at all.
What trips people up
Magento\Backend\App\AbstractAction declares ADMIN_RESOURCE as Magento_Backend::admin. Leave the constant out of your controller and it inherits that value, which every signed-in admin holds, including a role created for one warehouse clerk. Hiding the menu item changes nothing, because the URL still works.
acl.xml is read through the configuration cache. After editing it, run bin/magento cache:flush and reopen the role form, or your new resource will not be there to check.
Example
<!-- app/code/Vendor/Loyalty/etc/acl.xml -->
<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="Magento_Backend::marketing">
<resource id="Vendor_Loyalty::points" title="Loyalty Points" translate="title" sortOrder="50">
<resource id="Vendor_Loyalty::points_adjust" title="Adjust Point Balance" translate="title" sortOrder="10"/>
<resource id="Vendor_Loyalty::points_export" title="Export Point Ledger" translate="title" sortOrder="20"/>
</resource>
</resource>
</resource>
</resources>
</acl>
</config>When to use it
- Let a support role adjust a loyalty balance but not export the whole point ledger.
- Stop a new admin controller from being reachable by every signed-in admin user.
- Hide a custom admin menu item from roles that cannot use the page behind it.
- Declare a resource that a web API endpoint can then name in webapi.xml.
More examples
The controller that names the resource
A role without Vendor_Loyalty::points_adjust gets the permission-denied page and execute() never runs. The reader sees this at /admin/loyalty/points/adjust: a 200 with the denial page for the wrong role, a redirect back to the grid with a success message for the right one. Reach for this on every single admin controller you write, including the ones you think nobody will find.
<?php
// app/code/Vendor/Loyalty/Controller/Adminhtml/Points/Adjust.php
declare(strict_types=1);
namespace Vendor\Loyalty\Controller\Adminhtml\Points;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Backend\Model\View\Result\Redirect;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;
use Vendor\Loyalty\Api\PointLedgerInterface;
class Adjust extends Action implements HttpPostActionInterface
{
public const ADMIN_RESOURCE = 'Vendor_Loyalty::points_adjust';
public function __construct(
Context $context,
private readonly PointLedgerInterface $ledger
) {
parent::__construct($context);
}
public function execute(): ResultInterface
{
$customerId = (int) $this->getRequest()->getParam('customer_id');
$points = (int) $this->getRequest()->getParam('points');
$this->ledger->adjust($customerId, $points);
$this->messageManager->addSuccessMessage(__('Balance adjusted.'));
/** @var Redirect $redirect */
$redirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
return $redirect->setPath('*/*/index');
}
}The menu item, hidden by the same resource
Marketing gains a "Loyalty Points" entry for roles holding Vendor_Loyalty::points, and the entry is absent for the rest. Note the shorthand: <add ... /> is the self-closing form of <add ...></add> and means the same thing, which is also true of every <resource> element in acl.xml. Apply the change with bin/magento cache:flush. The menu is presentation only, so it never replaces ADMIN_RESOURCE on the controller.
<!-- app/code/Vendor/Loyalty/etc/adminhtml/menu.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
<menu>
<add id="Vendor_Loyalty::points"
title="Loyalty Points"
translate="title"
module="Vendor_Loyalty"
sortOrder="20"
parent="Magento_Backend::marketing"
action="loyalty/points/index"
resource="Vendor_Loyalty::points"/>
</menu>
</config>Checking a permission outside a controller
isAllowed($resource, $privilege = null) is the same check _isAllowed() performs, available anywhere you can inject. Reach for it when a page is allowed but one button on it is not, so the button is not drawn for a role that would be refused on click. The controller behind the button still needs its own ADMIN_RESOURCE.
<?php
// app/code/Vendor/Loyalty/ViewModel/Adminhtml/ExportButton.php
declare(strict_types=1);
namespace Vendor\Loyalty\ViewModel\Adminhtml;
use Magento\Framework\AuthorizationInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class ExportButton implements ArgumentInterface
{
public function __construct(
private readonly AuthorizationInterface $authorization
) {
}
public function canExport(): bool
{
return $this->authorization->isAllowed('Vendor_Loyalty::points_export');
}
}
// Called from a template, canExport() returns true only for a role
// that holds Vendor_Loyalty::points_export.
Discussion