Agora

Agora integration

Opt-in bridges to the Agora ecosystem — tenant auto-scope, the global-role bridge, event-driven provisioning, and the /authz/can endpoint.

All four features below are opt-in and default-OFF. None of them adds a hard dependency: with the rest of Agora absent, each one degrades to its default and authz keeps working standalone with unchanged behavior.

Tenant auto-scope

When a permission check gets no explicit tenant and your tenant resolver yields none, default the tenant to the active Agora context's tenantId.

resolveTenant is a function() => string | undefined — called on every check that still has no tenant. The package ships one ready to use, tenantFromContext, which reads the active Agora context's tenantId:

config/authz.ts
import { defineConfig, stores, tenantFromContext } from '@adonis-agora/authz'

export default defineConfig({
  default: 'lucid',
  stores: { lucid: stores.lucid() },
  resolveTenant: tenantFromContext,
})

Any zero-argument function works, so you are never tied to the Agora context — read the tenant from wherever your app already keeps it:

config/authz.ts
import { HttpContext } from '@adonisjs/core/http'

export default defineConfig({
  // ...
  resolveTenant: () => HttpContext.get()?.request.header('x-tenant'),
})

Precedence: explicit scope arg → tenant resolver → resolveTenant. With resolveTenant unset (the default), no context means the global '' scope, as before.

Global-role bridge

Bridge an auth provider's global roles — the ones authkit puts on the active Agora context — into authz checks at check time, with nothing seeded in the database.

defineConfig({
  // A user holding one of these global roles is allowed everything.
  superAdminRoles: ['platform:super'],
  // Role → permissions/wildcards, unioned into permission checks.
  roleGrants: {
    auditor: ['audit.*'],
  },
})

A super-admin global role short-circuits every decision — can, hasRole, hasAnyRole and scope — to allow. Otherwise, the permissions roleGrants maps onto the user's effective roles are unioned with the store's grants and the whole set goes through the wildcard matcher.

roleGrants applies to the user's effective roles — the context claim, the resolveRoles seam and the store alike — so it is not limited to global ones. See Effective roles for the full model, and Roles for how superAdminRoles composes with the superAdmin hook.

Reading the context yourself

The two features above are built on a small set of readers the package exports, and you can use them for your own wiring. Each one degrades to a safe default when the Agora context is not there, so calling them is never conditional:

import {
  globalRolesFromContext,
  readContextValue,
  tenantFromContext,
} from '@adonis-agora/authz'

tenantFromContext()       // the active tenant id, or undefined
globalRolesFromContext()  // the token's global roles, or []
readContextValue('locale') // any other key the context carries, or undefined
ExportReturnsWithout a context
tenantFromContext()The active tenant id.undefined
globalRolesFromContext()The user's global role names.[]
readContextValue(key)Any value the context carries.undefined
readContextAccessor()The context accessor itself.undefined
AGORA_CONTEXT_ACCESSORThe symbol the accessor is published under.

tenantFromContext is the reader you pass straight to resolveTenant; globalRolesFromContext is the one superAdminRoles and the effective-role union consult. Reach for readContextAccessor / AGORA_CONTEXT_ACCESSOR only when you are writing something that publishes or replaces a context — a test harness, for example.

Event-driven provisioning

defineAuthzProvisioning subscribes to authkit's diagnostics events (via the optional @adonis-agora/diagnostics peer) and runs config-mapped actions against the store. It is best-effort — actions never throw into the host, and a missing diagnostics package is a no-op.

start/authz_provisioning.ts
import { defineAuthzProvisioning } from '@adonis-agora/authz/provisioning'

const authz = await app.container.make(AuthzService)

await defineAuthzProvisioning({
  store: authz.store,
  on: {
    'organization.created': (ev, store) => {
      if (!ev.accountId || !ev.orgId) return
      return store.assignRole({ type: 'user', id: ev.accountId }, 'org:owner', {
        tenantId: ev.orgId,
      })
    },
    'member.added': (ev, store) => {
      if (!ev.accountId || !ev.orgId) return
      return store.assignRole({ type: 'user', id: ev.accountId }, 'org:member', {
        tenantId: ev.orgId,
      })
    },
  },
})

Keys are the bare authkit event type (subscribed on channels agora:authkit:<type>). The call returns a handle with .stop() to unsubscribe.

Read the ids from the top level of the event, not from metadata. authkit redacts its audit event before publishing it on the diagnostics bus: email, ip and the free-form metadata are stripped, and what reaches a subscriber is the event type plus the opaque accountId, actorId, clientId and orgId. An action written against ev.metadata.orgId reads undefined in production. metadata survives only when the host feeds the bus itself, or wires defineAuthzProvisioning to authkit's un-redacted events.onEvent.

The /authz/can endpoint

registerCanEndpoint registers a route that answers a permission check for the active user. Optional peer: @adonisjs/core (router types — already a peer).

start/routes.ts
import router from '@adonisjs/core/services/router'
import { registerCanEndpoint } from '@adonis-agora/authz/http'

const authz = await app.container.make(AuthzService)
registerCanEndpoint(router, { service: authz }) // default path: /authz/can

Contract (the frontend depends on this exactly):

  • Request JSON: { "permission": string, "resource"?: string }
  • Response JSON: { "allowed": boolean }

An anonymous/unmappable user or a missing permission yields { "allowed": false }.

Override the path and user resolution as needed:

registerCanEndpoint(router, {
  service: authz,
  path: '/api/authz/can',
  resolveUser: (ctx) => ctx.auth?.user,
})

On this page