Route middleware
AuthzRoleMiddleware — an any-of requireRole route guard on the user's effective roles (global ∪ app ∪ store), with guest and denied redirects.
Where Bouncer abilities protect a controller
action, AuthzRoleMiddleware protects a route — it is the "require role X"
guard every app otherwise rewrites by hand. It gates on the user's
effective roles (global ∪ app ∪ store), so a
role asserted by a token claim, resolved from your domain tables, or assigned in
the authz store all satisfy the same guard.
The middleware is exported as the default export of the
@adonis-agora/authz/middleware subpath.
Register it
Wire it as a named middleware so routes can pass their own roles:
import router from '@adonisjs/core/services/router'
export const middleware = router.named({
requireRole: () => import('@adonis-agora/authz/middleware'),
})Guard routes
Pass the accepted roles per route. It is any-of: the request passes if the user holds at least one.
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
// A single role.
router
.get('/coordinator', [CoordinatorController, 'index'])
.use(middleware.requireRole({ roles: ['COORDINATOR'] }))
// Any-of: EITHER role passes.
router
.get('/reports', [ReportsController, 'index'])
.use(middleware.requireRole({ roles: ['COORDINATOR', 'DIRECTOR'] }))
// Redirect instead of 401/403 (server-rendered / Inertia apps).
router
.group(() => {
router.get('/admin', [AdminController, 'dashboard'])
})
.use(
middleware.requireRole({
roles: ['ADMIN'],
guestRedirect: '/login',
deniedRedirect: '/unauthorized',
}),
)RequireRoleOptions
| Option | Type | Default | Behavior |
|---|---|---|---|
roles | string[] | — | Required. Accepted roles (any-of). |
scope | TenantScope? | — | Tenant scope forwarded to effectiveRoles. |
guestRedirect | string? | — | Where to send an unauthenticated request. Without it → 401 { message: 'Unauthenticated' }. |
deniedRedirect | string? | — | Where to send a request that is authenticated but lacks the role. Without it → 403. |
deniedMessage | string? | 'Forbidden' | The 403 body's message when there is no deniedRedirect. |
How it decides
- Resolve the user from
ctx.auth.getUser()(authkit) orctx.auth.user(a@adonisjs/authguard) — either works, and neither is required. - No user →
guestRedirectif set, else401 Unauthenticated. - Compute
authz.effectiveRoles(user, options.scope)and check that at least one ofoptions.rolesis present. - No matching role →
deniedRedirectif set, else403withdeniedMessage. - Otherwise call
next().
Because it reads effective roles, effectiveRoles populates the request's
global roles along the way — so roles: ['ADMIN'] matches a token claim even
when nothing is seeded in the store.
authorizeByRoles — the same gate for dashboards
Every @adonis-agora dashboard (telescope, durable, media, agent) gates its
routes with an authorize hook — a (ctx) => boolean decision the guard
turns into a 401/403 (or honors a redirect the hook writes). authorizeByRoles
is that hook, built on the exact same engine as the middleware:
import { defineConfig } from '@adonis-agora/telescope/ui'
import { authorizeByRoles } from '@adonis-agora/authz'
export default defineConfig({
authorize: authorizeByRoles({ roles: ['ADMIN'] }),
})It accepts any context shape (the dashboards type authorize differently —
durable uses HttpContext, telescope a framework-light slice), reading
ctx.auth structurally and never touching AdonisJS internals, so one RBAC gate
reads the same across every dashboard:
export default defineConfig({
authorize: authorizeByRoles({ roles: ['ADMIN', 'OPS'] }),
})How it decides
- Resolve the user from
ctx.auth.getUser()(authkit) orctx.auth.user(any guard) — with or without authkit. - No user →
false(the dashboard guard answers401/403). WithloginPath, a page navigation is redirected to your login first — see below. - Compute
authz.effectiveRoles(user, scope)and return whether at least one ofrolesis present.
Sending an expired session to your login page
By default a visitor with no session gets the dashboard's own denial — a "401 — you need to be signed in" page with no way out. The dashboard can say that login is missing; it cannot ask for login. Getting in means walking over to the app by hand, letting the session refresh, and coming back.
loginPath closes that loop:
export default defineConfig({
authorize: authorizeByRoles({ roles: ['ADMIN'], loginPath: '/auth/login' }),
})Now opening the console without a session redirects to
/auth/login?redirect=%2Ftelescope, and your login route sends the visitor back
when it is done. Every @adonis-agora dashboard honors a location written by
the hook before writing its own denial, so this works across all of them.
Two deliberate limits:
- Only when there is no session. Someone signed in who merely lacks the role
keeps getting
403. Redirecting them would loop: the login is already done, and coming back yields the same denial. - Only page navigations (
Accept: text/html). A302on the dashboard's own API call would hand the SPA'sfetchthe login HTML where it expects JSON — trading an honest401for a parse error.
The return-to is built from the request URL, which the server already knows — there is no open redirect to introduce here. Your login route still has to validate what it receives: it is public, and anyone can craft that link by hand.
AuthorizeByRolesOptions
| Option | Type | Default | Behavior |
|---|---|---|---|
roles | string[] | — | Required. Accepted roles (any-of). |
scope | TenantScope? | — | Tenant scope forwarded to effectiveRoles. |
loginPath | string? | — | Where to send a page navigation that has no session. Omit to keep the dashboard's own 401/403. |
returnToParam | string? | 'redirect' | Query parameter carrying the return-to. Use whatever your login route reads. |
Want a custom decision instead of roles — an IP allow-list, an environment
gate, a redirect to your own login page? Write the authorize hook by hand;
the helper is a convenience for the common "any-of these roles" case.
AuthzService.effectiveRoles
The middleware's engine is a public method you can call directly. It is the union of the three role sources authz recognizes:
effectiveRoles(user, scope?) = context/global roles (token claim)
∪ app roles (the `resolveRoles` seam)
∪ store roles (assigned in the authz DB)import authz from '@adonis-agora/authz/services/main'
const roles = await authz.effectiveRoles(user) // global scope
const scoped = await authz.effectiveRoles(user, { tenantId: 'acme' })- The global roles come from the active Agora context store (written by authkit) — no DB seeding.
- The app roles come from the optional
resolveRolesconfig seam (e.g. auser_rolesdomain table). - The store roles are those assigned via
store.assignRole(...).
The reverse lookup — who holds a role — is
usersWithRole, which
reads the same three sources.
It returns a de-duplicated union, tenant-filtered by scope. An
anonymous/unmappable user yields []. This is the exact set hasRole /
hasAnyRole check against, and the same set buildAuthzShare pushes to the
frontend, so the route guard, the Bouncer ability and the
UI never disagree.
Need a permission guard on a route rather than a role? Check the
permission inside the action with a Bouncer ability
(ctx.bouncer.authorize('can', 'posts.edit')), or filter a collection with a
query scope. AuthzRoleMiddleware is deliberately
role-only — the coarse gate that keeps whole route trees behind a role.