Agora

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:

app/models/authz_role.ts
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:

app/models/user.ts
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 page

That 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:

app/models/team.ts
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_type is part of the key. authz users are polymorphic: the primary key of authz_subject_role is (subject_type, subject_id, role_id, tenant_id), so admin#1 and user#1 are different subjects that share an id. A relation that joins on subject_id alone mixes them.
  • The global tenant is the empty string, not null. The store writes '' (GLOBAL_TENANT) for an unscoped assignment. Filtering the pivot on null matches no row at all.

So the returned object is:

KeyValue
pivotTableauthz_subject_role
localKey / relatedKeyyour model's primary key (whatever it is called) / id on the role model
pivotForeignKeysubject_id
pivotRelatedForeignKeyrole_id
onQuerywherePivot('subject_type', …) plus the tenant filter below

Options

authzRolesRelation({ tables, subjectType, tenantId, source, localKey })
OptionDefaultWhen you need it
tablesthe default namesThe 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.
sourceevery sourceRead only the assignments of one source (e.g. 'scim'). Without it, a role held via several sources still preloads once.
localKeythe model's primary keyThe 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 resolveRoles returns. The relation sees none of that. Decisions still go through can / hasRole / effectiveRoles.
  • It is for reading. Writes stay on the store (assignRole / removeRole, or the mixin methods that delegate to them): assignRole creates the role when it does not exist yet and is idempotent, and it is what fills subject_type and tenant_id. The relation's pivot filters live in onQuery, which constrains queries — attaching through the relation does not write those columns, and subject_type is NOT NULL with no default.

On this page

Partner

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

Become a partner →