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:
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:
| Direction | Store | Your domain tables | Global / IdP roles |
|---|---|---|---|
| forward — roles of a user | built in | resolveRoles | the token claim |
| reverse — users of a role | built in | resolveRoleMembers | resolveGlobalRoleMembers |
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:
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
superAdminhook runs first. If the hook returnsfalsethe request is denied andsuperAdminRolesis never consulted — a hookfalseis a hard deny that outranks the role. If the hook returnstruethe check allows; anything nullish falls through to the role list. See super-admin resolution. - Only global roles count.
superAdminRolesis matched against the token's global roles, not the full effective union. Assigningplatform:superin the store or returning it fromresolveRolesmakes it an ordinary role — it will satisfyhasRole('platform:super')and pick up anyroleGrantsyou 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,getSubjectsForRoleand 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? })— withoutsource, the role goes from every source (whatremoveRolealways 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
sourceis 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:
- The column heals itself.
ensureSchema()/createAuthzTables()addsourcenon-destructively (ADD COLUMN … NOT NULL DEFAULT 'manual'); every existing assignment becomes a manual one. WithautoCreateSchema: true(the default) this happens on boot. WithautoCreateSchema: false, add a migration that callscreateAuthzTables(db)again (it is idempotent) — the store's queries need the column. - 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 overridetables:
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.
The authz service
AuthzService and the services/main singleton — can, scope, hasRole, hasAnyRole, effectiveRoles, effectivePermissions, subjectsWithRole, the super-admin resolution and the per-request permission cache.
Query scopes
Register a scope filter for a resource so accessibleBy() constrains a collection to the rows a user may see — the scopes config key, ScopeRegistry.register, and the eq/where/whereIn/and/or DSL.