Roles relation
authzRolesRelation() — the Lucid manyToMany options that join your user model to the authz roles through the authz_subject_role pivot, so a listing preloads roles instead of asking once per user.
getRolesForSubject — and the mixin's getRoles() on top of
it — answers for one user, with one query. That is the right shape for a
request that already has a user in hand, and the wrong shape for a list: a
paginated screen of 20 users costs 20 extra round trips, and the cost grows with
the page.
authzRolesRelation() returns the options of a Lucid manyToMany joining any
model of yours — users, teams, organizations, whatever holds a primary key — to
the authz roles through the authz_subject_role pivot, so roles arrive over a JOIN
with preload instead:
import { authzRolesRelation } from '@adonis-agora/authz'
@manyToMany(() => AuthzRole, authzRolesRelation())
declare roles: ManyToMany<typeof AuthzRole>The role model is yours
The package does not ship the model on purpose. A model has to be bound to the
host's connection, and apps disagree about what the property should be called —
mapping the name column onto a role property is common when consumers already
spell it that way. What is dangerous is the pivot, and that is the part
authzRolesRelation() owns.
A minimal model over the store's authz_roles table:
import { BaseModel, column } from '@adonisjs/lucid/orm'
export default class AuthzRole extends BaseModel {
static table = 'authz_roles'
@column({ isPrimary: true })
declare id: string
// The column is `name`; call the property whatever your app already calls it.
@column({ columnName: 'name' })
declare role: string
}Then declare the relation on the user model:
import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import { authzRolesRelation } from '@adonis-agora/authz'
import AuthzRole from '#models/authz_role'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare id: number
@manyToMany(() => AuthzRole, authzRolesRelation())
declare roles: ManyToMany<typeof AuthzRole>
}const users = await User.query().preload('roles').paginate(page, 20)
// one query for the users, one for every role of every user on the pageThat is the whole declaration, and it is not users-only: the store is
polymorphic, so any table whose rows
hold roles — teams, organizations, service accounts — gets the same relation on
its own model. The primary key can be an increments() number, a
bigIncrements(), a UUID, and it does not have to be called id:
export default class Team extends BaseModel {
@column({ isPrimary: true })
declare id: number
// Teams are their own subject kind: `subjectType` is the `type` your
// resolveSubjectRef returns for a Team, and what assignRole(team, …) wrote.
@manyToMany(() => AuthzRole, authzRolesRelation({ subjectType: 'team' }))
declare roles: ManyToMany<typeof AuthzRole>
}const teams = await Team.query().preload('roles')Any key type, any pivot type
The pivot stores subject_id as TEXT by default — it is
polymorphic, so integer ids and UUIDs
share one table — and the relation matches your model's key against it whatever
the key's type: increments(), bigIncrements(), UUID. preload, load and
related('roles').query() work with the key as you declared it, on the default
TEXT pivot and on integer / bigint pivots
alike. Nothing to declare, nothing to configure.
has / whereHas('roles') on Postgres with an integer PK needs a native
pivot. Those compare the real columns in SQL (users.id = authz_subject_role.subject_id), and Postgres refuses integer = character varying — as a query error, never as a silent []. On Postgres, set
subjectIdType: 'integer' / 'bigint'
to match your PK, or ask the store instead (getSubjectsForRole). preload
is unaffected.
What the function spells for you
The pivot's shape is an internal of this package, not of your app, and two of its columns are easy to get wrong in a way that raises no error — you just read rows from the wrong scope:
subject_typeis part of the key. authz users are polymorphic: the primary key ofauthz_subject_roleis(subject_type, subject_id, role_id, tenant_id), soadmin#1anduser#1are different subjects that share an id. A relation that joins onsubject_idalone mixes them.- The global tenant is the empty string, not
null. The store writes''(GLOBAL_TENANT) for an unscoped assignment. Filtering the pivot onnullmatches no row at all.
So the returned object is:
| Key | Value |
|---|---|
pivotTable | authz_subject_role |
localKey / relatedKey | your model's primary key (whatever it is called) / id on the role model |
pivotForeignKey | subject_id |
pivotRelatedForeignKey | role_id |
onQuery | wherePivot('subject_type', …) plus the tenant filter below |
Options
authzRolesRelation({ tables, subjectType, tenantId, source, localKey })| Option | Default | When you need it |
|---|---|---|
tables | the default names | The store was configured with table overrides. Only subjectRole is read here — the roles table name lives on the model's static table. |
subjectType | 'user' | This model is not the default subject kind — i.e. resolveSubjectRef returns another type for it. It must match what assignRole wrote. |
tenantId | '' (global) | The relation should read a tenant's assignments as well as the global ones. |
source | every source | Read only the assignments of one source (e.g. 'scim'). Without it, a role held via several sources still preloads once. |
localKey | the model's primary key | The pivot references an attribute that is not the primary key. |
The tenant filter mirrors the store's, so the relation and
getRolesForSubject(user, scope) agree: a global read sees global rows only,
and a tenant read sees that tenant's rows plus the global ones — a
globally-assigned role holds inside every tenant. That is the visibility rule
described in multi-tenancy.
tenantId is fixed when the model is defined. It cannot follow a
per-request tenant, and a wherePivot added in preload ANDs with the one in
onQuery rather than widening it. For a request-scoped tenant, read through
the service (getRolesForSubject(user, { tenantId })) instead of the relation.
It reads the store, not the effective roles
Two limits worth keeping straight, both of them the reason this is a convenience for listings and not a shortcut for authorization:
- It is store rows only. The
effective-role union also folds in
the identity provider's global roles and whatever
resolveRolesreturns. The relation sees none of that. Decisions still go throughcan/hasRole/effectiveRoles. - It is for reading. Writes stay on the store (
assignRole/removeRole, or the mixin methods that delegate to them):assignRolecreates the role when it does not exist yet and is idempotent, and it is what fillssubject_typeandtenant_id. The relation's pivot filters live inonQuery, which constrains queries — attaching through the relation does not write those columns, andsubject_typeisNOT NULLwith no default.