Agora

Roles

authz as the single authority on roles — the effective-role union, the reverse lookup subjectsWithRole(), the resolveRoleMembers / resolveGlobalRoleMembers seams, and superAdminRoles.

An app almost never keeps its roles in one place. Some arrive as a claim on the token your identity provider issued. Some live in a domain table you already own — a user_roles join, a coordinator_id column, a membership model. Some are assigned through the authz store. Ask three different modules "is this user a coordinator?" and you get three different answers, each right about its own source and wrong about the other two.

@adonis-agora/authz is the place that reconciles them. It does not move your roles: it takes them where they are, unions them, and answers every role question — forwards and backwards — off that one union. Nothing else in the app should be asking a role source directly.

The effective-role union

A user's effective roles are the union of three sources:

effectiveRoles(user, scope?)  =  global roles   (the token claim, via authkit)
                               ∪  app roles      (the `resolveRoles` seam)
                               ∪  store roles    (assigned via store.assignRole)
import authz from '@adonis-agora/authz/services/main'

await authz.effectiveRoles(user)                      // global scope
await authz.effectiveRoles(user, { tenantId: 'acme' }) // tenant scope
// ['COORDINATOR', 'editor']

The result is de-duplicated and tenant-filtered. An anonymous or unmappable user yields []. This is the exact set hasRole and hasAnyRole test against, the set the route middleware guards on, the set a query-scope filter receives as ctx.roles, and the set buildAuthzShare pushes to the frontend — so the guard, the ability and the UI cannot disagree.

Wire the app-roles source with the resolveRoles config key, and map any of those roles onto permissions with roleGrants.

effectiveRoles answers for one user at a time. When what you need is the store's assignments for a whole page of users — a listing, an export — read them over a relation instead of once per row: see roles relation.

The reverse question: subjectsWithRole

Forward lookups ("which roles does this user hold?") answer authorization. Backward lookups ("who holds this role?") answer everything else: notify every coordinator, assign a ticket to an on-call admin, render an org chart, export an audit of privileged accounts.

subjectsWithRole is the mirror of effectiveRoles — same union, read in the other direction:

import authz from '@adonis-agora/authz/services/main'

const coordinators = await authz.subjectsWithRole('COORDINATOR')
// [{ type: 'user', id: '12' }, { type: 'user', id: '31' }]

const scoped = await authz.subjectsWithRole('COORDINATOR', { tenantId: 'acme' })

It returns SubjectRef objects — { type, id } — because authz never owns a users table. Hydrate them with your own model:

app/services/notify_coordinators.ts
import authz from '@adonis-agora/authz/services/main'
import User from '#models/user'

export async function notifyCoordinators(tenantId: string, message: string) {
  const refs = await authz.subjectsWithRole('COORDINATOR', { tenantId })
  const ids = refs.filter((ref) => ref.type === 'user').map((ref) => ref.id)
  if (ids.length === 0) return

  const users = await User.query().whereIn('id', ids)
  await Promise.all(users.map((user) => user.notify(message)))
}

The tenant argument behaves exactly like it does on hasRole: pass one and you get that tenant's holders plus the global ones; pass nothing and the configured tenant resolvers decide. Duplicates across sources are collapsed by (type, id), so a user who holds the role in two places appears once.

The reverse seams

The forward union reads three sources, so the reverse union has to read three sources too. authz can walk its own store on its own — it cannot walk your tables or your identity provider, because it has no idea what shape they are. So each of those two gets a config seam, the exact reverse counterpart of the forward one:

DirectionStoreYour domain tablesGlobal / IdP roles
forward — roles of a userbuilt inresolveRolesthe token claim
reverse — users of a rolebuilt inresolveRoleMembersresolveGlobalRoleMembers
config/authz.ts
import { defineConfig, stores } from '@adonis-agora/authz'
import Account from '#models/account'
import SubjectRole from '#models/subject_role'

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

  // Forward: which app roles does this user hold?
  resolveRoles: async (user, scope) => {
    const rows = await SubjectRole.query()
      .where('subject_id', user.id)
      .if(scope?.tenantId, (query) => query.where('tenant_id', scope!.tenantId!))
    return rows.map((row) => row.role)
  },

  // Reverse: who holds this app role?
  resolveRoleMembers: async (role, scope) => {
    const rows = await SubjectRole.query()
      .where('role', role)
      .if(scope?.tenantId, (query) => query.where('tenant_id', scope!.tenantId!))
    return rows.map((row) => row.subjectId)
  },

  // Reverse: who holds this role as a global / IdP role?
  resolveGlobalRoleMembers: async (role) => {
    const accounts = await Account.query().whereJsonSuperset('global_roles', [role])
    return accounts.map((account) => ({ type: 'user', id: account.id }))
  },
})

Both seams may return bare ids ('12', 42) or full references ({ type: 'account', id: '12' }). A bare id is read as the default user type — the same normalization every other entry point applies — so you only spell out type when you keep more than one kind of subject.

Both receive the active TenantScope as their second argument. Honour it when your source is tenant-aware and ignore it when it is not: global roles usually are not, which is why the example above takes only role.

An unconfigured seam contributes nothing, silently. With neither seam wired, subjectsWithRole answers from the authz store alone — it returns a shorter list, not an error. That is the failure mode to watch for: a coordinator whose role comes from user_roles never gets notified, and nothing anywhere reports a problem. If resolveRoles reads a source, resolveRoleMembers must read the same source, or the two directions describe different worlds.

The three lookups run in parallel, so adding a seam costs the latency of its own query, not the sum.

superAdminRoles — global roles that allow everything

Some roles are not "a bundle of permissions" but "skip the check". superAdminRoles names the global roles that short-circuit to allow, without seeding a single row:

config/authz.ts
defineConfig({
  superAdminRoles: ['platform:super'],
})

A user whose token carries platform:super now passes can() for any permission, hasRole() for any role name, and resolves to allow-all in scope() — so accessibleBy adds no WHERE at all and they see every row.

Two things worth knowing about how it composes:

  • The superAdmin hook runs first. If the hook returns false the request is denied and superAdminRoles is never consulted — a hook false is a hard deny that outranks the role. If the hook returns true the check allows; anything nullish falls through to the role list. See super-admin resolution.
  • Only global roles count. superAdminRoles is matched against the token's global roles, not the full effective union. Assigning platform:super in the store or returning it from resolveRoles makes it an ordinary role — it will satisfy hasRole('platform:super') and pick up any roleGrants you mapped onto it, but it will not skip checks. Keep "who may bypass authorization" an identity-provider decision, not a row anyone with store access can write.

For an ordinary broad role, prefer roleGrants with a wildcard — { auditor: ['audit.*'] } — over superAdminRoles. A wildcard grant is still a grant: it goes through the matcher, respects tenancy, and shows up in effectivePermissions. A super-admin role is invisible to all three.

Role sources (manual vs SSO/SCIM)

Every store assignment records who put it there in a source string. The default is 'manual' (the admin UI, the API, node ace authz:assign), and everything assigned before sources existed is manual too. An IdP sync writes under its own source, so it can never clobber a grant an admin made by hand.

import authz from '@adonis-agora/authz/services/main'

// An admin grants `editor` by hand (source: 'manual').
await authz.store.assignRole(ref, 'editor')

// The SCIM/SSO sync REPLACES only its own assignments: it adds/removes
// SCIM rows, and never touches manual ones (or other tenants).
await authz.store.setSubjectRoles(ref, ['editor', 'auditor'], { source: 'scim' })
await authz.store.setSubjectRoles(ref, ['auditor'], { source: 'scim', tenantId: 'acme' })

// The IdP drops `editor` → the SCIM row goes, the manual one keeps the role.
await authz.store.setSubjectRoles(ref, ['auditor'], { source: 'scim' })

await authz.store.getRoleAssignments(ref)
// [{ role: 'auditor', source: 'scim', tenantId: null },
//  { role: 'editor', source: 'manual', tenantId: null }]
  • A role held via two sources is two rows; removing one source keeps the role. getRolesForSubject, can/hasRole, getSubjectsForRole and the member counts stay distinct — a role is never listed or counted twice.
  • assignRole(user, role, { tenantId?, source? }) — idempotent per source.
  • removeRole(user, role, { tenantId?, source? }) — without source, the role goes from every source (what removeRole always meant); with it, only that source's row goes.
  • setSubjectRoles(user, roles, { source?, tenantId? }) — replaces one source's rows in one tenant scope (exact tenant, default global); creates missing roles. It runs in one transaction on the Lucid store, or joins the client you pass (client, withClient, resolveClient).
  • getRoleAssignments(user, scope?) — every row as { role, source, tenantId } (tenantId: null = global), with the usual tenant visibility.
  • A source is a non-empty string of at most 64 characters.

Migrating an existing Lucid schema

New tables are created with a source VARCHAR(64) NOT NULL DEFAULT 'manual' column that is part of the subject-role primary key (subject_type, subject_id, role_id, tenant_id, source). On MySQL, role_id in that table is declared CHARACTER SET ascii (role ids are UUIDs), which keeps the five-column key under InnoDB's 3072-byte limit with utf8mb4.

For a database created by an earlier version:

  1. The column heals itself. ensureSchema() / createAuthzTables() add source non-destructively (ADD COLUMN … NOT NULL DEFAULT 'manual'); every existing assignment becomes a manual one. With autoCreateSchema: true (the default) this happens on boot. With autoCreateSchema: false, add a migration that calls createAuthzTables(db) again (it is idempotent) — the store's queries need the column.
  2. Widen the primary key by hand. The old key is (subject_type, subject_id, role_id, tenant_id), so the same role can't be stored under a second source yet. Until you widen it, a second source's insert is silently ignored (the role stays recorded only under its first source). Everything else works. Run the SQL for your dialect once. It uses the default names, so adjust them if you override tables:

PostgreSQL

ALTER TABLE authz_subject_role DROP CONSTRAINT authz_subject_role_pkey;
ALTER TABLE authz_subject_role
  ADD PRIMARY KEY (subject_type, subject_id, role_id, tenant_id, source);

MySQL / MariaDB

ALTER TABLE authz_subject_role
  MODIFY role_id VARCHAR(191) CHARACTER SET ascii NOT NULL,
  DROP PRIMARY KEY,
  ADD PRIMARY KEY (subject_type, subject_id, role_id, tenant_id, source);

SQLite can't alter a primary key, so rebuild the table. If you configured subjectIdType, use INTEGER/BIGINT for subject_id:

BEGIN;
CREATE TABLE authz_subject_role_new (
  subject_type VARCHAR(191) NOT NULL,
  subject_id VARCHAR(191) NOT NULL,
  role_id VARCHAR(191) NOT NULL,
  tenant_id VARCHAR(191) NOT NULL DEFAULT '',
  source VARCHAR(64) NOT NULL DEFAULT 'manual',
  PRIMARY KEY (subject_type, subject_id, role_id, tenant_id, source)
);
INSERT INTO authz_subject_role_new (subject_type, subject_id, role_id, tenant_id, source)
  SELECT subject_type, subject_id, role_id, tenant_id, source FROM authz_subject_role;
DROP TABLE authz_subject_role;
ALTER TABLE authz_subject_role_new RENAME TO authz_subject_role;
CREATE INDEX IF NOT EXISTS authz_subject_role_subject_idx
  ON authz_subject_role (subject_type, subject_id);
COMMIT;

In a Lucid migration, run these statements with this.defer((db) => db.rawQuery(...)). The repository's test suite runs this exact SQL on real Postgres 16, MySQL 8 and SQLite.

On this page

Partner

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

Become a partner →