Skip to content

<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /guides/rest-middleware.md for this page in Markdown format</div>

Home | Changelog

REST Middleware & RBAC

This guide covers the middleware pipeline that runs before every REST controller action, how authentication and authorization policies are configured, and how response filtering and relation scoping work.


Overview

Every REST request passes through version resolution and then a four-middleware pipeline in RouterDispatcher::dispatch() before the controller action executes. The pipeline runs unconditionally — it cannot be opted out of per-controller. Policy configuration in application/config/rest_policies.php controls the behavior of each middleware for each controller and method.

Request
  → MY_Router                  (strips /v{N}/ segment, captures apiVersion)
  → RouterDispatcher           (resolves version, injects headers, swaps override controllers)
  → AuthenticationMiddleware   (extracts JWT, populates RequestContext)
  → AuthorizationMiddleware    (enforces policy auth type + roles, halts on 401/403)
  → ResourceContextMiddleware  (sets ResourceContext on controller)
  → RelationFilterMiddleware   (strips disallowed ?with= relations per scope)
  → Controller action

Pre-pipeline: Version Resolution

Before the middleware pipeline runs, RouterDispatcher resolves the API version at lines 21-59:

  1. MY_Router::_parse_routes() extracts the version number from /rest/v{N}/... URLs and rewrites the URI to strip the version segment, so rest_routes.php needs no per-version routes (application/core/MY_Router.php:31-37).
  2. VersionResolver::resolve() maps the version to a VersionResult DTO with lifecycle status, deprecation dates, and controller overrides (src/Rest/Versioning/VersionResolver.php:35-56).
  3. Unknown versions return 400 ("Invalid API version"). Obsolete versions return 410 Gone with Deprecation, Sunset, and Link headers pointing to the latest version.
  4. If a controller override exists for the requested version, RouterDispatcher swaps the controller class before DI instantiation (RouterDispatcher.php:52-54).
  5. Version response headers (Api-Version, and optionally Deprecation/Sunset/Link) are injected on all responses.
  6. The resolved apiVersion is passed into RequestContext and is available to middleware and controllers.

See SY-31 REST API Versioning for the full flow documentation.


Middleware Chain

1. AuthenticationMiddleware

Reads the Authorization: Bearer &lt;token> header, validates the JWT, and populates a RequestContext object with the authenticated identity and roles. This middleware never halts the request — if no token is present or the token is invalid, it produces an anonymous context. Halting on missing/invalid tokens is the responsibility of AuthorizationMiddleware.

2. AuthorizationMiddleware

Resolves the effective policy for the incoming controller + method combination and enforces it. It halts with:

  • 401 Unauthorized — when the policy requires authentication but the request context is anonymous.
  • 403 Forbidden — when the request is authenticated but the identity lacks the required role.

Auth types:

Auth typeBehavior
noneNo check — all requests pass through, even with no token.
guestSame as none — public endpoint, no token required.
anyRequires a valid JWT of any type (customer or backend).
customerRequires a valid customer JWT.
backendRequires a valid backend (admin) JWT.
legacy_guardDelegates to the controller's own guard trait (backwards compatibility).

3. ResourceContextMiddleware

After authorization passes, this middleware sets a ResourceContext instance on the controller. The context carries the resolved scope (public, customer, backend) and is available to Resource transformers for response field filtering. This runs for all controllers automatically — no opt-in required.

4. RelationFilterMiddleware

Reads the ?with= query parameter and strips any relation names that are not in the allowed list for the current scope. Stripping is silent — no error is returned for disallowed relations, they are simply removed before the controller sees the parameter. This prevents clients from leaking data via unintended eager-loaded relations.


Policy Configuration

Policies are defined in application/config/rest_policies.php. The file has two top-level keys: defaults for global fallback behavior, and controllers for per-controller configuration.

Structure

php
return [
    'defaults' => [
        'auth'  => 'backend',   // fallback auth type for any controller not listed
        'roles' => [],          // no role restriction by default (any backend user)
    ],

    'controllers' => [
        // Legacy: Adv_products_admin → ADVISABLE, ADMIN, PRODUCTS
        \Advisable\Rest\Product\Controllers\Product::class => [
            'defaults' => [
                'auth'  => 'backend',
                'roles' => [AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS],
            ],
            'methods' => [
                'index' => ['auth' => 'guest'],
                'show'  => ['auth' => 'guest'],
                'item'  => ['auth' => 'guest'],
            ],
        ],
    ],
];

Resolution Order

For any incoming request, the effective policy is resolved in this order (first match wins):

  1. Method-levelcontrollers[ControllerClass]['methods'][action]
  2. Controller-level defaultscontrollers[ControllerClass]['defaults']
  3. Global defaultsdefaults

This means you can make read endpoints public (guest) while keeping write endpoints backend-only, without repeating the backend policy on every write method.

Example: Public reads, role-restricted writes

php
// Legacy: Adv_blog_admin → ADVISABLE, ADMIN, CMS
BlogArticle::class => [
    'defaults' => ['auth' => 'backend', 'roles' => [AUTH_ROLE_ADMIN, AUTH_ROLE_CMS]],
    'methods'  => [
        'index' => ['auth' => 'guest'],
        'show'  => ['auth' => 'guest'],
        'item'  => ['auth' => 'guest'],
    ],
],

Role System

The RBAC role system mirrors the legacy admin role system. Roles are integers stored as constants (application/config/constants.php) and embedded in the JWT payload at login time. They are checked only when the policy roles array is non-empty.

ConstantValueDescription
AUTH_ROLE_ADVISABLE1Superuser — bypasses all role checks in middleware
AUTH_ROLE_DEVELOPER2Developer / technical settings
AUTH_ROLE_ADMIN3General admin (high-privilege, but NOT auto-bypass)
AUTH_ROLE_CMS4CMS / content management
AUTH_ROLE_PRODUCTS5Product catalog management
AUTH_ROLE_ORDERS6Order management
AUTH_ROLE_REPORTING7Reporting and analytics
AUTH_ROLE_MARKETING8Marketing / campaigns / promotions
AUTH_ROLE_MEDIA9Media / sliders / assets

Role hierarchy

  • AUTH_ROLE_ADVISABLE (1) is the only automatic bypass. Any JWT carrying this role skips all further role checks in AuthorizationMiddleware.
  • AUTH_ROLE_ADMIN (3) is high-privilege but does not auto-bypass. It must be explicitly listed in each roles array where allowed. Some routes (e.g. Audit, AI Settings) are ADVISABLE-only and intentionally exclude ADMIN.
  • Domain roles (2, 4–9) grant access to specific functional areas.

Roles are assigned to admin users via the user_role database table and embedded in the JWT at login. A user may have multiple roles. The policy roles array is an allowlist — the authenticated identity must have at least one of the listed roles (or AUTH_ROLE_ADVISABLE) to proceed.

Legacy alignment

Every roles array in rest_policies.php has been verified against the corresponding legacy admin controller's allowRole() check. Each entry includes a legacy provenance comment documenting the source:

php
// Legacy: Adv_sliders_admin → ADVISABLE, ADMIN, MARKETING, MEDIA
Slider::class => [
    'defaults' => ['auth' => 'backend', 'roles' => [AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA]],
    ...
],

Convention: list all allowed roles explicitly (except ADVISABLE, which auto-bypasses). When adding new REST controllers, check the legacy admin controller's allowRole() and replicate the same roles.


Response Filtering

ResourceContextMiddleware sets a ResourceContext on every controller before the action runs. Resource transformers receive this context and conditionally include or exclude fields based on the caller's scope.

Scope-based field filtering

All resources with mixed public/backend access implement field filtering. Public and customer callers receive only storefront-safe fields; backend callers see everything.

Pattern:

php
protected function resource(): array
{
    $data = [
        'id'    => $this->resource->id,
        'price' => $this->resource->price,
        // ... always-public fields
    ];

    if ($this->context?->isBackend()) {
        $data['wholesalePrice']  = $this->resource->wholesale_price;
        $data['adminComments']   = $this->resource->admin_comments;
        // ... backend-only fields
    }

    return $this->addRelationToData($data);
}

Fields filtered per resource

ResourceBackend-only fields (hidden from guest/customer)
ProductwholesalePrice, acquisitionValue, pointFactor, softDelete, negativeStock, hits, productView, hsCode, dateChanged, skroutzName, vendorCode, shelfcodeId, active, cartProductCustomization*
Categorypublished, isSensitive, asProductsDetails, menuColor, order, sliderId, menuSliderId, extraSliderId
CustomerisInsecure, totalPoints, hasAccess, isGuest, isSanitized, adminComments, erpId + relations: campaigns, messageHistory, smsMarketing, tags, audiences
OrderadminComments, adminOrderCheck, ipAddress, userAgent
BlogArticlepublished, promo, hits, sliderId
BlogCategoryactive, priority
BlogTagactive, priority
BlogAuthorpriority
Documentactive, position
VideoisPromo
Pagemenu, pageView, order, published, createdAt
Offerord
Eventactive, dateChanged
SlidergroupPriority, slidesLimit
SlidedateStart, dateEnd, audienceId, themeClass, priority

Resources that are fully public (Cookie, Subcontent, Country, County, Currency, Vat) or fully backend-only (Coupon, Gift, all Transporter settings) do not need filtering.

Available context methods

MethodTrue when
isPublic()No token or guest/none auth
isCustomer()Valid customer JWT
isBackend()Valid backend (admin) JWT
isAuthenticated()Either customer or backend
getUserId()Returns authenticated user's ID
hasRole(int $roleId)Check for a specific role

Context is also available in write actions (store, update), allowing controllers to accept or reject request body fields based on the caller's scope.


Relation Filtering

The relations key in a controller's policy maps scope names to lists of allowed relation names for that scope:

php
'relations' => [
    'guest'    => ['category', 'images'],
    'customer' => ['category', 'images', 'variants'],
    'backend'  => ['category', 'images', 'variants', 'attributes', 'vendor'],
],

When a request arrives with ?with=attributes,images, RelationFilterMiddleware resolves the current scope from RequestContext and strips any relation not in that scope's list. In the example above, a guest request for ?with=attributes,images will receive only imagesattributes is silently removed.

If no relations key is defined for a controller, no stripping occurs and the full ?with= value is passed through.


Adding a New Controller

By default, any controller not listed in rest_policies.php inherits the global defaults — which is backend auth with no role restriction. This is the safe default: new endpoints are backend-only until explicitly relaxed.

To make read endpoints public:

php
// application/config/rest_policies.php
\Advisable\Rest\MyContext\Controllers\MyEntity::class => [
    'defaults' => ['auth' => 'backend'],
    'methods'  => [
        'index' => ['auth' => 'guest'],
        'show'  => ['auth' => 'guest'],
    ],
],

To add role requirements (check legacy admin controller's allowRole() and replicate):

php
// Legacy: Adv_products_admin → ADVISABLE, ADMIN, PRODUCTS
'defaults' => ['auth' => 'backend', 'roles' => [AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS]],

To expose an endpoint to authenticated customers:

php
'index' => ['auth' => 'customer'],

Client Repos

Client repos can override application/config/rest_policies.php to add policies for custom controllers registered under the Custom\Rest\ namespace. The file is a plain PHP array return — replace or merge it as needed. When a custom controller is not listed, it inherits the global defaults (backend auth), which is the recommended safe starting point.


Key Files

FilePurpose
application/config/rest_policies.phpCentralized policy configuration (auth types + roles per controller/method)
application/config/constants.phpRole constant definitions (AUTH_ROLE_*)
src/Rest/Middleware/AuthenticationMiddleware.phpJWT extraction and RequestContext population
src/Rest/Middleware/AuthorizationMiddleware.phpPolicy enforcement, 401/403 responses
src/Rest/Middleware/ResourceContextMiddleware.phpSets ResourceContext on controller
src/Rest/Middleware/RelationFilterMiddleware.phpStrips disallowed ?with= relations
src/Rest/Middleware/RequestContext.phpCarries authenticated identity, roles, and resolved policy
src/Rest/Support/Resources/ResourceContext.phpCarries scope (public/customer/backend) for field filtering
application/controllers/RouterDispatcher.phpRuns the middleware pipeline before controller dispatch