scoped roles (multi tenancy)
Three scoping mechanisms — role-level scope, permission-level scope, and scoped role assignments. Pick the right one for your tenancy model.
Three scoping mechanisms
Scopes restrict roles to specific tenants, organizations, or workspaces.
| Mechanism | Use when |
|---|---|
| Role-level scope | The whole role is inherently tenant-bound |
| Permission-level scope | Only some grants on the role are restricted |
| Scoped assignment | Same role definition, different tenants per user |
1. Role-level scope
A scope on a role limits all its permissions to that scope:
const orgEditor = defineRole('org-editor')
.name('Org Editor')
.scope('org-1')
.grant('create', 'post')
.grant('update', 'post')
.build()const orgEditor = defineRole('org-editor')
.name('Org Editor')
.scope('org-1')
.grant('create', 'post')
.grant('update', 'post')
.build()When converted to policy rules, each rule gets an extra scope eq "org-1" condition. The permission only fires when the request scope matches.
2. Permission-level scope
Scope individual permissions by passing an optional third argument to grant():
const hybridRole = defineRole('hybrid')
.name('Hybrid Role')
.grant('read', 'post') // global — no scope restriction
.grant('update', 'post', 'org-1') // only in org-1
.grant('create', 'comment', 'org-2') // only in org-2
.build()const hybridRole = defineRole('hybrid')
.name('Hybrid Role')
.grant('read', 'post') // global — no scope restriction
.grant('update', 'post', 'org-1') // only in org-1
.grant('create', 'comment', 'org-2') // only in org-2
.build()read works everywhere; update only in org-1; create only in org-2.
grantScoped(scope, action, resource) does the same thing with scope first — pure stylistic preference.
3. Scoped role assignments
The most flexible: a user can have editor globally and admin only in org-1:
// In the adapter / admin API:
await engine.admin.assignRole('user-1', 'editor') // global
await engine.admin.assignRole('user-1', 'admin', 'org-1') // scoped
// When checking access:
const allowed = await engine.can(
'user-1',
'delete',
{ type: 'post', attributes: {} },
undefined, // environment
'org-1', // scope
)
// user-1 has admin in org-1, so delete is allowed// In the adapter / admin API:
await engine.admin.assignRole('user-1', 'editor') // global
await engine.admin.assignRole('user-1', 'admin', 'org-1') // scoped
// When checking access:
const allowed = await engine.can(
'user-1',
'delete',
{ type: 'post', attributes: {} },
undefined, // environment
'org-1', // scope
)
// user-1 has admin in org-1, so delete is allowedWhen a request carries a scope, the engine merges matching scoped role assignments into the subject. The admin role only joins subject.roles for requests with scope: "org-1".
Combining global + scoped
Users routinely have both:
await engine.admin.assignRole('alice', 'viewer') // global viewer
await engine.admin.assignRole('alice', 'admin', 'org-acme') // admin only in org-acmeawait engine.admin.assignRole('alice', 'viewer') // global viewer
await engine.admin.assignRole('alice', 'admin', 'org-acme') // admin only in org-acmeRequest behavior:
| Request scope | Effective roles |
|---|---|
'org-acme' | ['viewer', 'admin'] |
'org-other' | ['viewer'] (admin scope doesn't match) |
undefined | ['viewer'] (global only) |
This is the recommended pattern for multi-tenant SaaS — one global "platform user" role + tenant-specific admin roles per org.
Scoped roles + inheritance
Inherited permissions keep honoring scope constraints. If org-editor (scoped to org-1) inherits from org-viewer (scoped to org-1), the inherited viewer permissions also only fire in org-1.
Scope doesn't propagate up — a scoped role inheriting from a global role keeps the global role's permissions global. Inheritance flattens permissions, but scope constraints attached to those permissions travel with them.
Wildcard scope
scope: '*' matches every scope, including requests without one:
const globalEditor = defineRole('global-editor')
.grant('update', 'post', '*') // matches any scope, including unscoped
.build()const globalEditor = defineRole('global-editor')
.grant('update', 'post', '*') // matches any scope, including unscoped
.build()Useful when you have a mostly-scoped role with a few permissions that should apply universally.
Choosing between mechanisms
Quick decision guide:
- One reusable role + assigned per tenant? Scoped assignment (
engine.admin.assignRole(user, role, scope)). - Role definition itself differs by tenant? Tenant-specific role IDs (
org-acme-admin,org-globex-admin). - Whole role is inherently tenant-bound? Role-level scope (
defineRole(...).scope('org-1')). - Mostly-global role with a few scoped grants? Permission-level scope (
grant(action, resource, 'org-1')).
Prefer scoped assignments when permission shape is the same across tenants. Tenant-specific role IDs become hard to maintain — every permission change has to be replicated across all tenant role definitions.