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 is growing. Multiple organizations use the platform. Alice is an admin in Acme Corp but only a viewer in Globex Inc. You will add scoped roles so the same user can have different permissions in different tenants.
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 })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 })The assignments init option sets base (unscoped) roles. For scoped roles, use
adapter.assignRole(subjectId, roleId, scope) after construction. Base roles
apply everywhere. Scoped roles are added on top when a scope is provided.
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 also returns Alice's scoped assignments via
getSubjectScopedRoles(). The engine stores these as aScopedRole[]array. - Merge --
enrichSubjectWithScopedRoles()filters scoped roles matching the request scope (acme) and merges them into the subject's roles. Duplicates are removed. - Resolve inheritance -- the merged roles
['viewer', 'admin']are expanded. Admin inherits editor which inherits viewer, so the effective roles include all three. - 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: {},
// }The scopedRoles are stored but not merged into roles until a scope is provided in a
permission check. This way, the cached subject 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
)You can 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
duck-iam supports scoping at three levels:
1. Assignment-Level Scoping (Most Common)
Different roles per scope, as shown above. This is what most multi-tenant apps need.
// After creating the adapter with base roles:
await adapter.assignRole('alice', 'admin', 'acme')
await adapter.assignRole('alice', 'viewer', 'globex')// After creating the adapter with base roles:
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. Simple and flexible.
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()Setting .scope('acme') makes ALL permissions in this role scoped to acme. It is a
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 and most flexible. Use permission-level or role-level scoping 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 |
Key rule: If a permission has a scope (from role, permission, or assignment), the request must provide a matching scope. If the request has no scope, only global (unscoped) permissions match.
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 hierarchy is used when either the pattern or resource type contains a dot. Otherwise, colon-based matching is used. You can use either convention -- just be consistent in your application.
Scoped Permissions in Batch Checks
When using engine.permissions(), include scope in the checks:
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 so you can see exactly what was 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
You can 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()You can also use the .scope() and .scopes() shortcuts 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
)And .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'))
)Chapter 5 FAQ
What happens if I do not pass a scope?
Only the subject's base roles are used. No scoped roles are added. This means a user who is only an admin in a specific organization will not have admin permissions without the scope parameter. Scoped permissions also do not match -- only global (unscoped) permissions are evaluated.
How do I get the scope from an HTTP request?
Common patterns: extract from the URL path (/api/orgs/acme/posts), from a request
header (X-Organization: acme), or from the JWT token claims. In Chapter 6, you will
configure server middleware to extract the scope automatically using the getScope
callback.
Are scoped roles cached separately?
The subject cache stores the base roles and ALL scoped roles for a user. When a scope is provided in a check, the engine filters the scoped roles at evaluation time (not at cache time). This means one cached entry works for any scope. The cache key is the subject ID, not subject + scope.
Can I use scope in policy conditions?
Yes. The scope is available as scope in conditions: .check('scope', 'eq', 'acme')
or via shortcuts .scope('acme') and .scopes('acme', 'globex'). You can also use
$scope as a dynamic variable in condition values to compare against other fields.
This adds an extra layer of tenant isolation beyond just scoped role assignments.
When should I use hierarchical resources?
When you have resource types that form a natural hierarchy. For example, a dashboard
with sub-sections (users, settings, analytics), or a project with sub-resources
(issues, documents, comments). Granting access to the parent automatically covers
all children, reducing the number of permissions you need to manage. Use dots for
visual clarity: dashboard.users reads better than dashboard:users.
What is the difference between dot and colon hierarchy?
Both support hierarchical matching. Dots use matchesResourceHierarchical() and colons
use matchesResource(). The engine automatically uses dot-based matching when either the
pattern or resource type contains a dot. Otherwise, colon-based matching is used. They
behave similarly: dashboard matches dashboard.users (dots) and org matches
org:project (colons). Pick one convention and use it consistently.
What is the environment parameter used for?
The environment carries request context like IP address, user agent, timestamp, and
custom fields. Use it in conditions to restrict access based on where/when the request
comes from: .env('ip', 'starts_with', '10.') for VPN-only access, or
.env('timestamp', 'lt', cutoff) for time-based restrictions. Server integrations
extract the environment automatically from HTTP request headers.