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.giveUserPermission({ type: 'user', id: '1' }, 'billing.view') // directWildcard 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.userHasPermission itself matches names exactly.
Polymorphic users
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.userHasPermission({ type: 'user', id: '1' }, 'system.manage') // falseThe default resolveUserRef 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 normalizeUserRef is the function
that canonicalizes them. Call it when you need the exact { type, id } a store
row is keyed by:
import { normalizeUserRef } from '@adonis-agora/authz'
normalizeUserRef('42') // { type: 'user', id: '42' }
normalizeUserRef(42) // { type: 'user', id: '42' }
normalizeUserRef({ id: 42 }) // { type: 'user', id: '42' }
normalizeUserRef({ 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.
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, returning that same builder — it does not run the query. So there are
two awaits: one for the constraint resolution, one for the query itself.
import { accessibleBy } from '@adonis-agora/authz/scope'
const scoped = await accessibleBy(Post.query(), authz, user, Post)
const posts = await scoped // …or `scoped.exec()` — now the rows
// super-admin: every post; non-privileged: ownership/tenant WHERE injected;
// unknown resource: no rows (fail-closed).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) ✗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, usersWithRole, the super-admin resolution and the per-request permission cache.