Agora

Concepts

Roles, permissions, wildcard matching, polymorphic users and multi-tenancy.

Roles & permissions

  • A permission is a string like posts.edit. Grant it to a role, or directly to a user.
  • A role is a named bundle of permissions. Assign roles to users.
  • A user's effective permissions are the union of every permission from their roles plus any direct grants.
await authz.store.createRole('editor')
await authz.store.givePermissionToRole('editor', 'posts.edit')
await authz.store.assignRole({ type: 'user', id: '1' }, 'editor')

await authz.store.giveSubjectPermission({ type: 'user', id: '1' }, 'billing.view') // direct

// Administration, without any host SQL against the store's tables:
await authz.store.countSubjectsForRole('editor')       // the "N users" KPI
await authz.store.countSubjectsByRole()                // whole matrix: { editor: 1, ... }
await authz.store.deleteRole('editor')              // grants + memberships + row → true; unknown → false
await authz.store.syncRolePermissions('viewer', ['posts.view'])  // replace a role's grants
await authz.store.getPermissionsForRoles(['editor', 'viewer'])   // { viewer: ['posts.view'] }
await authz.store.listRoleAssignments({ tenantId: 'acme' })      // raw rows of that tenant
await authz.store.removeSubject({ type: 'user', id: '1' })       // account deleted

Every store method can run inside the host's database transaction — per call with { client: trx }, or once with store.withClient(trx) — so a grant made next to User.create(...) commits or rolls back with it. See config → Joining a transaction, and The store API below for the full surface.

Wildcard matching

Only the granted side may use wildcards; the ability you check is always literal. Matching is segment-based on .:

GrantedRequiredMatches?
posts.editposts.edit✅
posts.*posts.edit✅
posts.*posts.edit.draft✅
posts.*posts❌ (a trailing * needs one-or-more remaining segments)
posts.*comments.edit❌
*anything✅
postsposts.edit❌

The matcher is exported, so you can run the same rules over a permission set you hold yourself — a snapshot from effectivePermissions, a set you fetched, a fixture in a test:

import { permissionMatches, permissionSatisfied } from '@adonis-agora/authz'

// one granted pattern against one required ability
permissionMatches('posts.*', 'posts.edit') // true
permissionMatches('posts.*', 'posts')      // false

// does ANY pattern in a granted set satisfy the ability?
permissionSatisfied(['billing.*', 'posts.read'], 'billing.refund') // true
permissionSatisfied(['billing.*', 'posts.read'], 'posts.write')    // false

permissionSatisfied is what AuthzService.can runs over the user's grants, and what useCan / <Can> run over the frontend share — which is why a permission set is always tested with it rather than with Array.includes, whose exact match would miss every wildcard.

Wildcard expansion happens in the service, not the database: the store returns a user's grant set, and AuthzService.can runs the matcher over it. store.subjectHasPermission itself matches names exactly.

Polymorphic subjects

The package never owns a users table. A user is a { type, id } reference, so the same tables serve admins, customers, API clients, etc.

await authz.store.assignRole({ type: 'admin', id: '1' }, 'superuser')
// A different type with the same id is a different subject:
await authz.store.subjectHasPermission({ type: 'user', id: '1' }, 'system.manage') // false

The default resolveSubjectRef reads user.id (and an optional user.type). Override it in config to map your own user shape.

Anywhere a reference is accepted, the shorthand forms are accepted too — a bare id, a number, an object without a type — and normalizeSubjectRef is the function that canonicalizes them. Call it when you need the exact { type, id } a store row is keyed by:

import { normalizeSubjectRef } from '@adonis-agora/authz'

normalizeSubjectRef('42')                        // { type: 'user', id: '42' }
normalizeSubjectRef(42)                          // { type: 'user', id: '42' }
normalizeSubjectRef({ id: 42 })                  // { type: 'user', id: '42' }
normalizeSubjectRef({ type: 'admin', id: 42 })   // { type: 'admin', id: '42' }

Ids are always stringified, so the numeric 42 and the string '42' are the same subject; a missing type always means 'user'.

Multi-tenancy

Role assignments can be scoped to a tenant. The empty string '' is the global scope.

await authz.store.assignRole({ type: 'user', id: '1' }, 'viewer', { tenantId: 'acme' })

Visibility rules:

  • A global request (no tenant) sees only global rows.
  • A tenant request sees global rows and that tenant's rows.
  • A tenant-scoped assignment never leaks into another tenant or the global scope.
  • Direct user-permission grants are tenant-independent — they always apply.

Wire the active tenant once via the tenant resolver in config and every check becomes tenant-aware automatically.

The global scope has a name — GLOBAL_TENANT, the empty string — and normalizeTenant collapses any TenantScope (including undefined) down to it, which is how a row's tenant column is keyed:

import { GLOBAL_TENANT, normalizeTenant } from '@adonis-agora/authz'

normalizeTenant({ tenantId: 'acme' }) // 'acme'
normalizeTenant(undefined)            // '' — GLOBAL_TENANT
normalizeTenant({})                   // ''

Use them when you write code that keys or groups by tenant alongside authz, so "no tenant" is spelled the same way on both sides.

The store API

authz.store (the PermissionStore from config) is the full admin surface — everything below works identically on the lucid and memory stores, and it is what a roles screen uses instead of SQL against the five RBAC tables. Every data method also takes a trailing { client } to join a host transaction — see Joining a transaction. Hosts with integer ids may give the pivots their native key type (subjectIdType: 'integer' | 'bigint') — a storage choice, not a correctness one; see config → Native key-type pivots.

Definition

MethodContract
createRole(name) → idIdempotent — assigning an unknown role creates it, so seeding is always re-runnable.
createPermission(name) → idIdempotent, same rule.
givePermissionToRole(role, permission)Attach; creates either side by name when missing.
revokePermissionFromRole(role, permission)Detach; no-op when either side is absent.
getRolePermissions(role)The permission names attached to a role.
getPermissionsForRoles(roles){ [role]: permissionNames } for many roles in one query. Every existing role is a key ([] when it has no grants); unknown roles are absent.
syncRolePermissions(role, permissions)Replaces the role's grants with exactly this list (spatie's syncPermissions), creating the role/permissions by name. Transactional.
listRoles() / listPermissions()Every known name (what node ace authz:list reads).
deleteRole(name) → booleanDeletes the role outright — revokes its grants, removes every user assignment (all tenants and sources), deletes the row, in one transaction. Returns whether the role existed (an unknown name is a no-op returning false). Whether to refuse a still-populated role is your decision: count first.

Assignments & direct grants

MethodContract
assignRole(user, role, scope?)Idempotent; creates the role when it does not exist. scope defaults to global; scope.source records who owns the assignment (default 'manual') — see role sources.
removeRole(user, role, scope?)Removes the assignment matching the exact tenant scope — from every source, or only scope.source when given.
setSubjectRoles(user, roles, { source?, tenantId? })Replaces ONE source's assignments in ONE tenant scope (transactional); other sources are untouched.
giveSubjectPermission(user, permission) / revokeSubjectPermission(...)Direct grants — tenant-independent by design.
removeSubject(user)Forgets the user: every role assignment (all tenants and sources) and every direct grant, in one transaction — for account deletion.

Reads

MethodContract
getRolesForSubject(user, scope?)Distinct role names, with the tenant-visibility rules above.
getRoleAssignments(user, scope?)Every assignment as { role, source, tenantId } (tenantId: null = global), same visibility.
getSubjectsForRole(role, scope?)The reverse: every { type, id } holding the role.
listRoleAssignments({ tenantId?, role?, subject?, source? })Raw { subjectType, subjectId, role, source, tenantId } rows for admin listings, ordered by (subjectType, subjectId, role, source, tenantId). tenantId omitted = every tenant; null = global rows only; a string = only that tenant's scoped rows (globals are not included — unlike the visibility rule above). role takes a name or a list.
countSubjectsForRole(role, scope?)Same visibility, reduced to a number — the "N users" KPI of a roles screen without transferring one ref per member.
countSubjectsByRole(scope?)Every role → member count in one GROUP BY pass; roles nobody holds come back as 0 (a complete matrix, not just rows with members).
getPermissionsForSubject(user, scope?)The raw granted names — role-derived ∪ direct. Wildcards are not expanded (the service's matcher does that).
subjectHasPermission(user, permission, scope?)Exact-name membership, no wildcards — prefer authz.can().

For a roles administration screen the pair countSubjectsByRole() (the table) and countSubjectsForRole(role) (the delete guard) covers the KPIs; when a delete must also check-and-act atomically, run both reads and the write on one client:

await db.transaction(async (trx) => {
  const authz = store.withClient(trx)
  if ((await authz.countSubjectsForRole('editor')) > 0) {
    throw new Error('role still has members') // the tx rolls back the check itself
  }
  await authz.deleteRole('editor')
})

A store implementation for a different backend must satisfy the same contract — see Testing → Writing your own store, whose method tables are the implementer's view of exactly this list.

Query scopes (accessibleBy)

Where can decides yes/no for a single resource, a query scope filters a collection to the rows a user may access — applied at the DB layer instead of over-fetch-then-filter (the Pundit policy_scope / Cerbos query-plan concept). Import the helpers from the @adonis-agora/authz/scope subpath.

This is the consumer side. A resource is deny-all until you register a scope filter for it — see Query scopes for the producer side (the scopes config key and the eq/where/and/or DSL).

accessibleBy resolves the constraint and applies it to the query builder you passed. The promise resolves to the rows, not to the builder — a Lucid builder is thenable (then() runs exec()), so awaiting the helper already runs the query: one await, and you hold the rows.

import { accessibleBy } from '@adonis-agora/authz/scope'

const posts = await accessibleBy(Post.query(), authz, user, Post)
// super-admin: every post; non-privileged: ownership/tenant WHERE injected;
// unknown resource: no rows (fail-closed).

Need to keep chaining after the scope (filter, order, paginate)? Then you want a builder, and the resolved value is an array — resolve the two halves yourself. authz.scope() gives the constraint and applyScopeConstraint() is synchronous and never executes:

const constraint = await authz.scope(user, Post)
const q = applyScopeConstraint(Post.query(), constraint)
const posts = await q.where('published', true).orderBy('title') // runs once, here

The query you pass MUST NOT have a top-level orWhere. The scope is appended with AND, and in SQL AND binds tighter than OR, so an AND (scope) glued onto a top-level OR only constrains the last branch — leaking rows, even past a deny-all (1 = 0). The helper cannot retroactively re-group clauses the caller already added.

Two safe patterns keep the top level free of orWhere:

// SAFE — apply the scope FIRST, then add only AND-ed filters:
const scoped = await accessibleBy(Post.query(), authz, user, Post)
const posts = await scoped.where('published', true) // ANDed: fine

// SAFE — wrap any caller-side OR inside its own group:
const base = Post.query().where((q) => q.where('id', 1).orWhere('id', 2))
const grouped = await accessibleBy(base, authz, user, Post)
await grouped // → (id = 1 OR id = 2) AND (scope)

// UNSAFE — top-level OR; the scope binds only to the last branch and leaks:
const bad = Post.query().where('id', 1).orWhere('id', 2)
await (await accessibleBy(bad, authz, user, Post)) // → id = 1 OR (id = 2 AND scope)  ✗

On this page

Partner

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

Become a partner →