Authorisation: who may call the route

Every REST route lists the permissions it needs: an ACL resource, self for the caller's own data, or anonymous for no token at all.

Syntax<resources><resource ref="anonymous | self | Vendor_Module::resource_id"/></resources>

Every REST route must state who may call it. The <resources> node inside a <route> lists what the caller needs, and the framework checks it before your service method runs. There are three kinds of entry.

The three kinds

An ACL resource such as Vendor_Reviews::reviews_delete is an identifier declared in the module's etc/acl.xml. It shows up as a checkbox under System > Permissions > User Roles, so an admin user or an integration can be granted it. ref="self" means the caller is acting on their own data; a customer token satisfies it, and it is normally paired with a <data> node that forces the customer id from the authenticated session, so a caller cannot pass someone else's. ref="anonymous" means no token is required at all. The route is open to anyone on the internet.

What trips people up

Two resources in one node are combined with AND, not OR: the caller must hold every one of them. A caller with a valid token but without the resource gets 401 and the message The consumer isn't authorized to access %resources., which reads like a sign-in failure but is a role problem.

The common accident is copying a route block, changing the method to DELETE and leaving ref="anonymous" behind. Read the resources node of every route before release. After editing acl.xml or webapi.xml run bin/magento cache:clean config config_webservice; a role that was limited to selected resources must then be edited to tick the new one.

Example

Example · xml
<!-- app/code/Vendor/Reviews/etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <!-- Open to anyone, no token: approved reviews on a product page. -->
    <route url="/V1/vendor-reviews/product/:sku" method="GET">
        <service class="Vendor\Reviews\Api\PublicReviewListInterface" method="getBySku"/>
        <resources>
            <resource ref="anonymous"/>
        </resources>
    </route>

    <!-- The signed-in customer's own reviews. The id cannot be supplied by the caller. -->
    <route url="/V1/vendor-reviews/mine" method="GET">
        <service class="Vendor\Reviews\Api\ReviewListInterface" method="getByCustomerId"/>
        <resources>
            <resource ref="self"/>
        </resources>
        <data>
            <parameter name="customerId" force="true">%customer_id%</parameter>
        </data>
    </route>

    <!-- Admin user or integration holding the resource. -->
    <route url="/V1/vendor-reviews/:reviewId" method="DELETE">
        <service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="deleteById"/>
        <resources>
            <resource ref="Vendor_Reviews::reviews_delete"/>
        </resources>
    </route>
</routes>

When to use it

  • Open one read-only endpoint to a storefront app without handing out tokens.
  • Let a signed-in customer edit only their own records, with the id taken from the token.
  • Give an integration delete rights on your entity without giving it the whole catalogue.

More examples

Declaring the ACL resource

A resource referenced in webapi.xml has to exist here, nested under Magento_Backend::admin, or no role can ever be granted it. The title is the label an administrator sees in the role tree and in the integration's resource list, so write it as a permission rather than as a feature name. Run bin/magento cache:clean config after editing, then reopen the role and tick the new box.

Example · xml
<!-- app/code/Vendor/Reviews/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="Vendor_Reviews::reviews" title="Product Reviews" sortOrder="80">
                    <resource id="Vendor_Reviews::reviews_save"
                              title="Create and Edit Reviews" sortOrder="10"/>
                    <resource id="Vendor_Reviews::reviews_delete"
                              title="Delete Reviews" sortOrder="20"/>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

What a permission failure looks like

This is the body of a 401 response. The message is a template and the parameters object carries the values, which is how Magento returns a localisable error. Reading the resources parameter tells you exactly which checkbox is missing from the role or the integration, so there is no need to guess: it names the resource the route asked for and the caller did not hold.

Example · json
{
    "message": "The consumer isn't authorized to access %resources.",
    "parameters": {
        "resources": "Vendor_Reviews::reviews_delete"
    }
}

Checking ownership inside the service

The resources node answers "may this kind of caller use this route". It cannot answer "does this row belong to this caller" when the id is not forced in webapi.xml. UserContextInterface tells you the caller type (1 integration, 2 admin, 3 customer, 4 guest) and the id, and an AuthorizationException becomes a 401. The promoted constructor parameter here is the same thing as declaring `private readonly UserContextInterface $userContext;` and assigning `$this->userContext = $userContext;` in the body - Magento core is written in that longer form, and both compile to the same object.

Example · php
<?php
// app/code/Vendor/Reviews/Model/ReviewOwnership.php

declare(strict_types=1);

namespace Vendor\Reviews\Model;

use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\Exception\AuthorizationException;

class ReviewOwnership
{
    public function __construct(
        private readonly UserContextInterface $userContext
    ) {
    }

    public function assertMayRead(int $ownerCustomerId): void
    {
        $type = $this->userContext->getUserType();

        if ($type === UserContextInterface::USER_TYPE_ADMIN
            || $type === UserContextInterface::USER_TYPE_INTEGRATION
        ) {
            return;
        }

        if ($type === UserContextInterface::USER_TYPE_CUSTOMER
            && (int) $this->userContext->getUserId() === $ownerCustomerId
        ) {
            return;
        }

        throw new AuthorizationException(__('You are not allowed to read this review.'));
    }
}

Discussion

  • Be the first to comment on this lesson.