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.

Syntaxpublic 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

Example · xml
<!-- 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.

Example · php
<?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.

Example · xml
<!-- 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.

Example · php
<?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

  • Be the first to comment on this lesson.