Skip to main content
Search...

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.

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 })
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 })

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

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 also returns Alice's scoped assignments via getSubjectScopedRoles(). The engine stores these as a ScopedRole[] array.
  3. Merge -- enrichSubjectWithScopedRoles() filters scoped roles matching the request scope (acme) and merges them into the subject's roles. Duplicates are removed.
  4. Resolve inheritance -- the merged roles ['viewer', 'admin'] are expanded. Admin inherits editor which inherits viewer, so the effective roles include all three.
  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: {},
// }

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

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 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:

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

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:

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 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.


Next: Chapter 6: Server Integration