Roles relation
authzRolesRelation() — the Lucid manyToMany options that join your user model to the authz roles through the authz_user_role pivot, so a listing preloads roles instead of asking once per user.
getRolesForUser — 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 your
user model to the authz roles through the authz_user_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 pageWhat 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:
user_typeis part of the key. authz users are polymorphic: the primary key ofauthz_user_roleis(user_type, user_id, role_id, tenant_id), soadmin#1anduser#1are different subjects that share an id. A relation that joins onuser_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_user_role |
localKey / relatedKey | id on the user model / id on the role model |
pivotForeignKey | user_id |
pivotRelatedForeignKey | role_id |
onQuery | wherePivot('user_type', …) plus the tenant filter below |
Options
authzRolesRelation({ tables, userType, tenantId })| Option | Default | When you need it |
|---|---|---|
tables | the default names | The store was configured with table overrides. Only userRole is read here — the roles table name lives on the model's static table. |
userType | 'user' | This model is not the default subject kind — i.e. resolveUserRef 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. |
The tenant filter mirrors the store's, so the relation and
getRolesForUser(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 (getRolesForUser(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 fillsuser_typeandtenant_id. The relation's pivot filters live inonQuery, which constrains queries — attaching through the relation does not write those columns, anduser_typeisNOT NULLwith no default.