Skip to main content

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.

Loading diagram...

Scoped Role Assignments

Set up base roles and scoped assignments

src/access.ts
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'
})
src/access.ts
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 mode option 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

src/main.ts
// 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
src/main.ts
// 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

How Scope Resolution Works

Loading diagram...

When a scope is passed to engine.can():

  1. Load base roles -- resolveSubject() gets Alice's assigned roles: ['viewer']
  2. Load scoped roles -- the adapter returns Alice's scoped assignments via getSubjectScopedRoles(), stored as a ScopedRole[] array.
  3. Merge -- enrichSubjectWithScopedRoles() filters scoped roles matching the request scope (acme) and merges them in. Duplicates are removed.
  4. Resolve inheritance -- ['viewer', 'admin'] is expanded. Admin inherits editor which inherits viewer, so all three are in effect.
  5. 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

LevelUse WhenExample
Assignment-levelUsers have different roles in different tenantsAlice is admin in Acme, viewer in Globex
Permission-levelA role has some global and some scoped permissionsOrg-admin can manage users in their org but read posts globally
Role-levelAn entire role is specific to one tenantacme-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:

PatternRequest ScopeResultExplanation
undefinedanymatchNo pattern = global (matches everything)
'*'anymatchWildcard matches everything
'acme''acme'matchExact match
'acme''globex'no matchDifferent scope
'acme'undefinedno matchScoped 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:

PatternResource TypeMatch?Why
'*'anythingyesWildcard
'dashboard''dashboard'yesExact match
'dashboard''dashboard.users'yesParent matches child
'dashboard''dashboard.users.settings'yesParent matches deep child
'dashboard.*''dashboard.users'yesWildcard child
'dashboard.*''dashboard'noWildcard requires child
'dashboard.users''dashboard.users.settings'yesSub-parent matches
'dashboard.users''dashboard.settings'noDifferent branch

Colon-Based Matching Rules

Resources and actions also support colon-based hierarchy:

PatternValueMatch?Why
'org''org:project'yesParent matches child
'org''org:project:doc'yesParent matches deep child
'org:*''org:project'yesWildcard child
'posts:*''posts:create'yesAction 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'))
)

Chapter 5 FAQ


Next: Chapter 6: Server Integration