chapter 5: multi tenant scoping
Add multi-tenant authorization to BlogDuck. Users get different roles in different organizations, and permissions are isolated per tenant.
Goal
BlogDuck serves multiple organizations. Alice is an admin in Acme Corp but only a viewer in Globex Inc. This chapter adds scoped roles so the same user can hold different permissions per tenant.
Scoped Role Assignments
Set up base roles and scoped assignments
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'], // base role (always active)
'bob': ['editor'],
'charlie': ['admin'],
},
})
// Assign scoped roles using the adapter API
await adapter.assignRole('alice', 'admin', 'acme') // alice is admin in acme
await adapter.assignRole('alice', 'viewer', 'globex') // alice is viewer in globex
await adapter.assignRole('bob', 'editor', 'acme')
await adapter.assignRole('bob', 'editor', 'globex')
export const engine = new Engine({
adapter,
// mode: 'production', // optional -- defaults to 'development'
})const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'], // base role (always active)
'bob': ['editor'],
'charlie': ['admin'],
},
})
// Assign scoped roles using the adapter API
await adapter.assignRole('alice', 'admin', 'acme') // alice is admin in acme
await adapter.assignRole('alice', 'viewer', 'globex') // alice is viewer in globex
await adapter.assignRole('bob', 'editor', 'acme')
await adapter.assignRole('bob', 'editor', 'globex')
export const engine = new Engine({
adapter,
// mode: 'production', // optional -- defaults to 'development'
})assignments sets base (unscoped) roles. For scoped roles, call
adapter.assignRole(subjectId, roleId, scope) after construction. Base roles
apply everywhere; scoped roles are added on top when a scope is provided.
The
modeoption controls return types and diagnostics, not authorization logic. Scoped roles, scope enrichment, and all permission checks work identically in both'development'and'production'modes.
Pass the scope when checking
// Alice in Acme: base(viewer) + scoped(admin) = admin permissions
const acmeResult = await engine.can(
'alice',
'manage',
{ type: 'user', attributes: {} },
undefined, // environment
'acme', // scope
)
console.log('Alice manage user in acme:', acmeResult) // true
// Alice in Globex: base(viewer) + scoped(viewer) = viewer permissions
const globexResult = await engine.can(
'alice',
'manage',
{ type: 'user', attributes: {} },
undefined,
'globex',
)
console.log('Alice manage user in globex:', globexResult) // false
// Alice without scope: just base(viewer)
const noScopeResult = await engine.can(
'alice',
'manage',
{ type: 'user', attributes: {} },
)
console.log('Alice manage user (no scope):', noScopeResult) // false// Alice in Acme: base(viewer) + scoped(admin) = admin permissions
const acmeResult = await engine.can(
'alice',
'manage',
{ type: 'user', attributes: {} },
undefined, // environment
'acme', // scope
)
console.log('Alice manage user in acme:', acmeResult) // true
// Alice in Globex: base(viewer) + scoped(viewer) = viewer permissions
const globexResult = await engine.can(
'alice',
'manage',
{ type: 'user', attributes: {} },
undefined,
'globex',
)
console.log('Alice manage user in globex:', globexResult) // false
// Alice without scope: just base(viewer)
const noScopeResult = await engine.can(
'alice',
'manage',
{ type: 'user', attributes: {} },
)
console.log('Alice manage user (no scope):', noScopeResult) // falseHow Scope Resolution Works
When a scope is passed to engine.can():
- Load base roles --
resolveSubject()gets Alice's assigned roles:['viewer'] - Load scoped roles -- the adapter returns Alice's scoped assignments via
getSubjectScopedRoles(), stored as aScopedRole[]array. - Merge --
enrichSubjectWithScopedRoles()filters scoped roles matching the request scope (acme) and merges them in. Duplicates are removed. - Resolve inheritance --
['viewer', 'admin']is expanded. Admin inherits editor which inherits viewer, so all three are in effect. - Build RBAC policy -- the
__rbac__policy includes rules for all resolved permissions.
The ScopedRole Type
interface ScopedRole {
role: string // the role ID
scope?: string // the scope this role applies to
}interface ScopedRole {
role: string // the role ID
scope?: string // the scope this role applies to
}When resolveSubject() loads a user, it also loads their scoped roles:
const subject = await engine.resolveSubject('alice')
// {
// id: 'alice',
// roles: ['viewer'], // base roles only
// scopedRoles: [
// { role: 'admin', scope: 'acme' },
// { role: 'viewer', scope: 'globex' },
// ],
// attributes: {},
// }const subject = await engine.resolveSubject('alice')
// {
// id: 'alice',
// roles: ['viewer'], // base roles only
// scopedRoles: [
// { role: 'admin', scope: 'acme' },
// { role: 'viewer', scope: 'globex' },
// ],
// attributes: {},
// }scopedRoles are not merged into roles until a scope is provided in a check. One cached
subject entry works for any scope.
The Environment Parameter
The fourth parameter in engine.can() is the environment:
interface Environment {
ip?: string // client IP address
userAgent?: string // client user agent string
timestamp?: number // current timestamp (milliseconds)
[key: string]: any // custom fields
}interface Environment {
ip?: string // client IP address
userAgent?: string // client user agent string
timestamp?: number // current timestamp (milliseconds)
[key: string]: any // custom fields
}// Pass environment data for condition checks
const result = await engine.can('alice', 'update',
{ type: 'post', attributes: {} },
{
ip: '192.168.1.1',
userAgent: 'Mozilla/5.0...',
timestamp: Date.now(),
region: 'us-east-1', // custom field
},
'acme', // scope
)// Pass environment data for condition checks
const result = await engine.can('alice', 'update',
{ type: 'post', attributes: {} },
{
ip: '192.168.1.1',
userAgent: 'Mozilla/5.0...',
timestamp: Date.now(),
region: 'us-east-1', // custom field
},
'acme', // scope
)Reference environment values in conditions with .env('ip', 'starts_with', '192.168.').
Server integrations (Chapter 6) extract the environment automatically from HTTP requests.
Three Levels of Scoping
1. Assignment-Level Scoping (Most Common)
Different roles per scope, as shown above.
await adapter.assignRole('alice', 'admin', 'acme')
await adapter.assignRole('alice', 'viewer', 'globex')await adapter.assignRole('alice', 'admin', 'acme')
await adapter.assignRole('alice', 'viewer', 'globex')The user gets their base roles plus scoped roles for the matching scope.
2. Permission-Level Scoping
Individual permissions within a role are limited to a scope:
const orgAdmin = defineRole('org-admin')
.grantScoped('acme', 'manage', 'user') // only in acme scope
.grant('read', 'post') // no scope = works everywhere
.build()const orgAdmin = defineRole('org-admin')
.grantScoped('acme', 'manage', 'user') // only in acme scope
.grant('read', 'post') // no scope = works everywhere
.build()When the __rbac__ policy is generated, scoped permissions get an additional condition:
{ field: 'scope', operator: 'eq', value: 'acme' }. The permission only matches when
the request scope matches.
3. Role-Level Scoping
The entire role is constrained to a scope:
const acmeEditor = defineRole('acme-editor')
.scope('acme')
.grant('create', 'post')
.grant('update', 'post')
.build()const acmeEditor = defineRole('acme-editor')
.scope('acme')
.grant('create', 'post')
.grant('update', 'post')
.build().scope('acme') makes all permissions in this role scoped to acme -- shorthand for
calling .grantScoped('acme', ...) on every permission.
When to Use Each Level
| Level | Use When | Example |
|---|---|---|
| Assignment-level | Users have different roles in different tenants | Alice is admin in Acme, viewer in Globex |
| Permission-level | A role has some global and some scoped permissions | Org-admin can manage users in their org but read posts globally |
| Role-level | An entire role is specific to one tenant | acme-editor only works in Acme |
Assignment-level is the simplest. Use permission-level or role-level when you need finer control.
Scope Matching Algorithm
The matchesScope() function determines if a scope matches:
| Pattern | Request Scope | Result | Explanation |
|---|---|---|---|
undefined | any | match | No pattern = global (matches everything) |
'*' | any | match | Wildcard matches everything |
'acme' | 'acme' | match | Exact match |
'acme' | 'globex' | no match | Different scope |
'acme' | undefined | no match | Scoped pattern requires a scope |
If a permission has a scope, the request must provide a matching scope. A request without a scope only matches global (unscoped) permissions.
Hierarchical Resources
Resource types can use dots to form hierarchies:
// Grant access to dashboard (parent)
const manager = defineRole('manager')
.grant('read', 'dashboard')
.build()
// This also grants access to dashboard.users, dashboard.settings, etc.
await engine.can('user-1', 'read', { type: 'dashboard.users', attributes: {} })
// true -- because 'dashboard' is a parent of 'dashboard.users'
await engine.can('user-1', 'read', { type: 'dashboard.settings', attributes: {} })
// true -- same parent match
await engine.can('user-1', 'read', { type: 'analytics', attributes: {} })
// false -- not a child of 'dashboard'// Grant access to dashboard (parent)
const manager = defineRole('manager')
.grant('read', 'dashboard')
.build()
// This also grants access to dashboard.users, dashboard.settings, etc.
await engine.can('user-1', 'read', { type: 'dashboard.users', attributes: {} })
// true -- because 'dashboard' is a parent of 'dashboard.users'
await engine.can('user-1', 'read', { type: 'dashboard.settings', attributes: {} })
// true -- same parent match
await engine.can('user-1', 'read', { type: 'analytics', attributes: {} })
// false -- not a child of 'dashboard'Dot-Based Matching Rules
The matchesResourceHierarchical() function handles dot-based hierarchy:
| Pattern | Resource Type | Match? | Why |
|---|---|---|---|
'*' | anything | yes | Wildcard |
'dashboard' | 'dashboard' | yes | Exact match |
'dashboard' | 'dashboard.users' | yes | Parent matches child |
'dashboard' | 'dashboard.users.settings' | yes | Parent matches deep child |
'dashboard.*' | 'dashboard.users' | yes | Wildcard child |
'dashboard.*' | 'dashboard' | no | Wildcard requires child |
'dashboard.users' | 'dashboard.users.settings' | yes | Sub-parent matches |
'dashboard.users' | 'dashboard.settings' | no | Different branch |
Colon-Based Matching Rules
Resources and actions also support colon-based hierarchy:
| Pattern | Value | Match? | Why |
|---|---|---|---|
'org' | 'org:project' | yes | Parent matches child |
'org' | 'org:project:doc' | yes | Parent matches deep child |
'org:*' | 'org:project' | yes | Wildcard child |
'posts:*' | 'posts:create' | yes | Action wildcard |
Dot-based matching is used when either the pattern or resource type contains a dot; otherwise colon-based matching is used. Pick one convention and be consistent.
Scoped Permissions in Batch Checks
When using engine.permissions(), include scope in each check:
const perms = await engine.permissions('alice', [
{ action: 'manage', resource: 'user', scope: 'acme' },
{ action: 'manage', resource: 'user', scope: 'globex' },
{ action: 'read', resource: 'post' }, // no scope
])
// {
// 'acme:manage:user': true,
// 'globex:manage:user': false,
// 'read:post': true,
// }const perms = await engine.permissions('alice', [
{ action: 'manage', resource: 'user', scope: 'acme' },
{ action: 'manage', resource: 'user', scope: 'globex' },
{ action: 'read', resource: 'post' }, // no scope
])
// {
// 'acme:manage:user': true,
// 'globex:manage:user': false,
// 'read:post': true,
// }Each check is evaluated with its own scope, so scoped role enrichment happens per-check.
Debugging Scoped Roles
Use explain() to verify which scoped roles are being applied:
const result = await engine.explain('alice', 'manage',
{ type: 'user', attributes: {} },
undefined,
'acme',
)
console.log('Roles:', result.subject.roles)
console.log('Scoped roles added:', result.subject.scopedRolesApplied)
// Roles: ['viewer']
// Scoped roles added: ['admin']const result = await engine.explain('alice', 'manage',
{ type: 'user', attributes: {} },
undefined,
'acme',
)
console.log('Roles:', result.subject.roles)
console.log('Scoped roles added:', result.subject.scopedRolesApplied)
// Roles: ['viewer']
// Scoped roles added: ['admin']The explain result separates base roles from scoped roles added for this scope.
If scopedRolesApplied is empty, check:
- Did you pass the scope parameter?
- Does the adapter have scoped assignments for this user and scope?
- Does the adapter implement
getSubjectScopedRoles()?
Using Scope in Conditions
Reference the scope in policy conditions:
const tenantPolicy = policy('tenant-isolation')
.algorithm('deny-overrides')
.rule('deny-cross-tenant', r => r
.deny()
.on('*')
.of('*')
.when(w => w
.exists('scope') // only apply when scope is present
.resourceAttr('tenantId', 'neq', '$scope') // deny if resource belongs to different tenant
)
)
.build()const tenantPolicy = policy('tenant-isolation')
.algorithm('deny-overrides')
.rule('deny-cross-tenant', r => r
.deny()
.on('*')
.of('*')
.when(w => w
.exists('scope') // only apply when scope is present
.resourceAttr('tenantId', 'neq', '$scope') // deny if resource belongs to different tenant
)
)
.build().scope() and .scopes() shortcuts are also available on the When builder:
.when(w => w
.scope('acme') // require scope to be 'acme'
)
.when(w => w
.scopes('acme', 'globex') // require scope to be one of these
).when(w => w
.scope('acme') // require scope to be 'acme'
)
.when(w => w
.scopes('acme', 'globex') // require scope to be one of these
).forScope() on the RuleBuilder restricts an entire rule to specific scopes:
.rule('acme-only-rule', r => r
.allow()
.on('manage')
.of('dashboard')
.forScope('acme')
.when(w => w.role('admin'))
).rule('acme-only-rule', r => r
.allow()
.on('manage')
.of('dashboard')
.forScope('acme')
.when(w => w.role('admin'))
)