Agora

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:

start/kernel.ts
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.

start/routes.ts
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

OptionTypeDefaultBehavior
rolesstring[]?—Accepted roles (any-of). Provide at least one of roles / permissions.
permissionsstring[]?—Accepted permissions (any-of), wildcard-aware. Only read if no roles matched.
scopeTenantScope?—Tenant scope forwarded to effectiveRoles / effectivePermissions.
guestRedirectstring?—Where to send an unauthenticated request. Without it → 401 { message: 'Unauthenticated' }.
deniedRedirectstring?—Where to send a request that is authenticated but lacks the role/permission. Without it → 403.
deniedMessagestring?'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

  1. Resolve the user from ctx.auth.getUser() (authkit) or ctx.auth.user (a @adonisjs/auth guard) — either works, and neither is required.
  2. No user → guestRedirect if set, else 401 Unauthenticated.
  3. Compute authz.effectiveRoles(user, scope); if options.roles is given, it passes when at least one role is present.
  4. If options.permissions is given and no role matched, compute authz.effectivePermissions(user, scope) and pass when any required permission is satisfied (wildcard-aware, either side).
  5. Still denied → onDenied(ctx, { roles, permissions }) if set, else deniedRedirect if set, else 403 with deniedMessage.
  6. 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:

config/telescope_ui.ts
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

  1. Resolve the user from ctx.auth.getUser() (authkit) or ctx.auth.user (any guard) — with or without authkit.
  2. No user → false (the dashboard guard answers 401/403). With loginPath, a page navigation is redirected to your login first — see below.
  3. Compute authz.effectiveRoles(user, scope) and return whether at least one of roles is 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:

config/telescope_ui.ts
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). A 302 on the dashboard's own API call would hand the SPA's fetch the login HTML where it expects JSON — trading an honest 401 for 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

OptionTypeDefaultBehavior
rolesstring[]—Required. Accepted roles (any-of).
scopeTenantScope?—Tenant scope forwarded to effectiveRoles.
loginPathstring?—Where to send a page navigation that has no session. Omit to keep the dashboard's own 401/403.
returnToParamstring?'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 resolveRoles config seam (e.g. a user_roles domain 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.

On this page

Partner

Your product as the official integration for Authz, right here.

Become a partner →