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 deletedEvery 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 .:
| Granted | Required | Matches? |
|---|---|---|
posts.edit | posts.edit | ✅ |
posts.* | posts.edit | ✅ |
posts.* | posts.edit.draft | ✅ |
posts.* | posts | ❌ (a trailing * needs one-or-more remaining segments) |
posts.* | comments.edit | ❌ |
* | anything | ✅ |
posts | posts.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') // falsepermissionSatisfied 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') // falseThe 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
| Method | Contract |
|---|---|
createRole(name) → id | Idempotent — assigning an unknown role creates it, so seeding is always re-runnable. |
createPermission(name) → id | Idempotent, 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) → boolean | Deletes 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
| Method | Contract |
|---|---|
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
| Method | Contract |
|---|---|
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, hereThe 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) ✗Getting started
Install @adonis-agora/authz, run the migration, grant a permission and check it through Bouncer.
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.