Agora

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:

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 per route. It is any-of: the request passes if the user holds at least one.

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'] }))

// 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

OptionTypeDefaultBehavior
rolesstring[]Required. Accepted roles (any-of).
scopeTenantScope?Tenant scope forwarded to effectiveRoles.
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. Without it → 403.
deniedMessagestring?'Forbidden'The 403 body's message when there is no deniedRedirect.

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, options.scope) and check that at least one of options.roles is present.
  4. No matching role → deniedRedirect if set, else 403 with deniedMessage.
  5. 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 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.

On this page