Appearance
<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>
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 actionPre-pipeline: Version Resolution
Before the middleware pipeline runs, RouterDispatcher resolves the API version at lines 21-59:
MY_Router::_parse_routes()extracts the version number from/rest/v{N}/...URLs and rewrites the URI to strip the version segment, sorest_routes.phpneeds no per-version routes (application/core/MY_Router.php:31-37).VersionResolver::resolve()maps the version to aVersionResultDTO with lifecycle status, deprecation dates, and controller overrides (src/Rest/Versioning/VersionResolver.php:35-56).- Unknown versions return 400 ("Invalid API version"). Obsolete versions return 410 Gone with
Deprecation,Sunset, andLinkheaders pointing to the latest version. - If a controller override exists for the requested version,
RouterDispatcherswaps the controller class before DI instantiation (RouterDispatcher.php:52-54). - Version response headers (
Api-Version, and optionallyDeprecation/Sunset/Link) are injected on all responses. - The resolved
apiVersionis passed intoRequestContextand 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 <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 type | Behavior |
|---|---|
none | No check — all requests pass through, even with no token. |
guest | Same as none — public endpoint, no token required. |
any | Requires a valid JWT of any type (customer or backend). |
customer | Requires a valid customer JWT. |
backend | Requires a valid backend (admin) JWT. |
legacy_guard | Delegates 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):
- Method-level —
controllers[ControllerClass]['methods'][action] - Controller-level defaults —
controllers[ControllerClass]['defaults'] - 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.
| Constant | Value | Description |
|---|---|---|
AUTH_ROLE_ADVISABLE | 1 | Superuser — bypasses all role checks in middleware |
AUTH_ROLE_DEVELOPER | 2 | Developer / technical settings |
AUTH_ROLE_ADMIN | 3 | General admin (high-privilege, but NOT auto-bypass) |
AUTH_ROLE_CMS | 4 | CMS / content management |
AUTH_ROLE_PRODUCTS | 5 | Product catalog management |
AUTH_ROLE_ORDERS | 6 | Order management |
AUTH_ROLE_REPORTING | 7 | Reporting and analytics |
AUTH_ROLE_MARKETING | 8 | Marketing / campaigns / promotions |
AUTH_ROLE_MEDIA | 9 | Media / sliders / assets |
Role hierarchy
AUTH_ROLE_ADVISABLE(1) is the only automatic bypass. Any JWT carrying this role skips all further role checks inAuthorizationMiddleware.AUTH_ROLE_ADMIN(3) is high-privilege but does not auto-bypass. It must be explicitly listed in eachrolesarray 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
| Resource | Backend-only fields (hidden from guest/customer) |
|---|---|
| Product | wholesalePrice, acquisitionValue, pointFactor, softDelete, negativeStock, hits, productView, hsCode, dateChanged, skroutzName, vendorCode, shelfcodeId, active, cartProductCustomization* |
| Category | published, isSensitive, asProductsDetails, menuColor, order, sliderId, menuSliderId, extraSliderId |
| Customer | Backend-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. |
| Order | adminComments, adminOrderCheck, ipAddress, userAgent |
| BlogArticle | published, promo, hits, sliderId |
| BlogCategory | active, priority |
| BlogTag | active, priority |
| BlogAuthor | priority |
| BlogComment | email, status (also withheld for storefront reads reached via ?with=comments[.children] on BlogArticle, and via children/parent at every nesting depth) |
| Document | active, position |
| Video | isPromo |
| Page | menu, pageView, order, published, createdAt |
| Offer | ord |
| Event | active, dateChanged |
| Slider | groupPriority, slidesLimit |
| Slide | dateStart, dateEnd, audienceId, themeClass, priority |
| TransporterSetting | regKey, 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
| Method | True 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
| File | Purpose |
|---|---|
application/config/rest_policies.php | Centralized policy configuration (auth types + roles per controller/method) |
application/config/constants.php | Role constant definitions (AUTH_ROLE_*) |
src/Rest/Middleware/AuthenticationMiddleware.php | JWT extraction and RequestContext population |
src/Rest/Middleware/AuthorizationMiddleware.php | Policy enforcement, 401/403 responses |
src/Rest/Middleware/FeatureGuardMiddleware.php | 404s storefront requests to disabled-feature endpoints; backend bypass |
src/Rest/Middleware/ResourceContextMiddleware.php | Sets ResourceContext on controller |
src/Rest/Middleware/RelationFilterMiddleware.php | Strips disallowed ?with= relations |
src/Rest/Middleware/AuditMiddleware.php | Logs authenticated writes to user_audit_logs |
application/config/rest_features.php | Feature→resolver map and controller→feature guard map |
src/Rest/Middleware/RequestContext.php | Carries authenticated identity, roles, and resolved policy |
src/Rest/Support/Resources/ResourceContext.php | Carries scope (public/customer/backend) for field filtering |
application/controllers/RouterDispatcher.php | Runs the middleware pipeline before controller dispatch |