Route middleware
AuthzRoleMiddleware — an any-of route guard on the user's effective roles (global ∪ app ∪ store) or permissions, with guest/denied redirects and a host-owned onDenied hook.
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 (and/or permissions) per route. Both lists are
any-of: the request passes if the user holds at least one role, or at least
one permission. Provide at least one of roles / permissions.
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'] }))
// Open by PERMISSION — `admin.*` admits a role created at runtime without any
// route listing it. The wildcard may sit on either side: the user's granted
// `admin.*` opens `admin.users`, and a route listed `admin.*` opens for a user
// who only holds `admin.users`.
router
.get('/admin', [AdminController, 'dashboard'])
.use(middleware.requireRole({ permissions: ['admin.*'] }))
// 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',
}),
)
// Or let the HOST decide the denial response (redirect per area, flash message).
router
.get('/painel', [PanelController, 'index'])
.use(
middleware.requireRole({
roles: ['ADMIN'],
onDenied: (ctx, { roles }) => {
ctx.session.flash('error', 'Sem acesso ao painel')
return ctx.response.redirect(roles.includes('COORDINATOR') ? '/coordenador' : '/')
},
}),
)RequireRoleOptions
| Option | Type | Default | Behavior |
|---|---|---|---|
roles | string[]? | — | Accepted roles (any-of). Provide at least one of roles / permissions. |
permissions | string[]? | — | Accepted permissions (any-of), wildcard-aware. Only read if no roles matched. |
scope | TenantScope? | — | Tenant scope forwarded to effectiveRoles / effectivePermissions. |
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/permission. Without it → 403. |
deniedMessage | string? | 'Forbidden' | The 403 body's message when there is no deniedRedirect / onDenied. |
onDenied | (ctx, { roles, permissions }) => unknown | — | The host decides the denial response (redirect, flash, 403). Receives the resolved effective roles, and the permissions — resolved lazily, only when permissions is set and no role matched, so it can be [] on a role-gated route. Overrides deniedRedirect/deniedMessage. |
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, scope); ifoptions.rolesis given, it passes when at least one role is present. - If
options.permissionsis given and no role matched, computeauthz.effectivePermissions(user, scope)and pass when any required permission is satisfied (wildcard-aware, either side). - Still denied →
onDenied(ctx, { roles, permissions })if set, elsedeniedRedirectif 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
subjectsWithRole, 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.
A route can gate on a permission too — requireRole({ permissions: ['admin.*'] }) reads effectivePermissions and opens for any wildcard match,
so an area admits roles created at runtime without editing routes. That is the
coarse gate. The fine checks still belong in the action — a Bouncer
ability (ctx.bouncer.authorize('can', 'posts.edit')) or a query scope for a collection —
which can see the per-resource context the route guard cannot.