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 six-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)
  → FeatureGuardMiddleware     (returns 404 if feature disabled; backend bypass — #287)
  → ResourceContextMiddleware  (sets ResourceContext on controller)
  → RelationFilterMiddleware   (strips disallowed ?with= relations per scope)
  → AuditMiddleware            (logs authenticated writes to user_audit_logs)
  → 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. FeatureGuardMiddleware ​

Returns 404 to the storefront when the target controller's feature is disabled in the registry. Backend tokens (resolved by AuthenticationMiddleware before this runs) bypass unconditionally — admins can always manage a disabled feature. Non-backend callers hitting a guarded endpoint when the feature is off receive {success:false, message:'Not Found'} with HTTP 404 and the pipeline halts.

The controller→feature mapping and the feature→resolver specs live in application/config/rest_features.php. The lookup falls back to get_parent_class() so a versioned override subclass inherits its base controller's gate. In v1, only Builder::class is guarded (feature builder, registry key BUILDER.ENABLED). Adding a new guard requires only a config edit — no code change.

This middleware is registered in src/Rest/Auth/container.php alongside the other pipeline middlewares.

4. 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.

5. 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-level — controllers[ControllerClass]['methods'][action]
  2. Controller-level defaults — controllers[ControllerClass]['defaults']
  3. Global defaults — defaults

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
CustomerBackend-or-self*: name, surname, mail, address, city, region, postal, county, country, countryDetails, gender, birthdate, landphone, mobilephone, all sendto*, companyName, companyAfm, companyDoy, profession, companyAddress, dateRegistered, lang, totalPoints. Backend-only: isInsecure, hasAccess, isGuest, isSanitized, adminComments, erpId + relations: campaigns, messageHistory, smsMarketing, tags, audiences. Only id is ungated.
OrderadminComments, adminOrderCheck, ipAddress, userAgent
BlogArticlepublished, promo, hits, sliderId
BlogCategoryactive, priority
BlogTagactive, priority
BlogAuthorpriority
BlogCommentemail, status (also withheld for storefront reads reached via ?with=comments[.children] on BlogArticle, and via children/parent at every nesting depth)
Documentactive, position
VideoisPromo
Pagemenu, pageView, order, published, createdAt
Offerord
Eventactive, dateChanged
SlidergroupPriority, slidesLimit
SlidedateStart, dateEnd, audienceId, themeClass, priority
TransporterSettingregKey, regValue (#614 — the Setting endpoint itself is backend + AUTH_ROLE_ADMIN, but the resource still gates these fields so a ?with=settings/?with=transporter.settings embed off a guest-readable endpoint can't surface them)

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

* Customer's personal fields are gated backend-or-self, not backend-only — the one exception to this table's usual "hidden from guest/customer" framing. They are visible to a backend/admin caller or to the very customer the row belongs to (an isAuthenticated() check alone would not be enough: on the guest-policy slider/audience routes an ordinary customer token would still satisfy it while reading somebody else's row). See CustomerResource::isSelfOrBackend() / isSelf() (Advisable-com/ecommercen#615). id is the only field emitted to every context, including a fully anonymous one.

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' => [
    'public'   => ['category', 'images'],
    'customer' => ['category', 'images', 'variants'],
    'backend'  => ['category', 'images', 'variants', 'attributes', 'vendor'],
    'default'  => [],
],

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 images — attributes is silently removed.

The three scope keys are public, customer and backend — exactly what RelationFilterMiddleware::resolveScope() returns, where public means an unauthenticated caller. A 'guest' key is dead config that never matches.

Always include 'default' => []. The middleware reads $relations[$scope] ?? $relations['default'] ?? null and skips all filtering when that resolves to null, so a scope with neither its own key nor a default fails open. The empty array denies everything for every scope not named explicitly.

Matching is on the top-level relation name, so denying products also denies products.categories. Bracketed per-relation params are matched by name and passed through untouched: with translations allowed, ?with=translations[el] and ?with=translations[el,en] both survive intact (the middleware splits and names segments via WithParser, so a comma inside [...] does not split one relation into two).

If no relations key is defined for a controller, no stripping occurs and the full ?with= value is passed through. Today Customer::class, Line::class, Transporter::class, Slider::class and Slide::class declare one — Customer (#551), Line (#612), Transporter (#614, storefront scopes limited to translations), and Slider/Slide (#615, denying audience to storefront scopes since it is the first hop of a PII chain into Audience.customers -> CustomerResource).

Scope is per policy, not per entity. The allow-list is enforced only for requests to the controller that declares it. Relation loading recurses across entity boundaries, so if the related entity is also exposed directly (or reachable through a relation) by a different guest-readable controller that declares no allow-list of its own, that data is still reachable by nesting through the other endpoint — denying a relation here does not deny that data platform-wide.


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/FeatureGuardMiddleware.php404s storefront requests to disabled-feature endpoints; backend bypass
src/Rest/Middleware/ResourceContextMiddleware.phpSets ResourceContext on controller
src/Rest/Middleware/RelationFilterMiddleware.phpStrips disallowed ?with= relations
src/Rest/Middleware/AuditMiddleware.phpLogs authenticated writes to user_audit_logs
application/config/rest_features.phpFeature→resolver map and controller→feature guard map
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