Skip to main content

Scoped roles for multi-tenancy

Role-level scope, permission-level scope, and scoped assignments - what each emits, and exactly how a request's scope is matched at evaluation time

A scope is a tenant, organization, or workspace identifier carried on the request. duck-iam offers three ways to bind a role to one: a scope on the role, a scope on a single permission, and a scope on the assignment that grants the role to a subject. The first two become a condition inside the generated policy; the third changes which roles a subject holds for that request. This page covers all three and the request-time matching path.

Choosing a mechanism

MechanismWhere it livesUse when
Role-level scope - .scope('org-1')The role definitionThe whole role is inherently tenant-bound
Permission-level scope - .grant(a, r, 'org-1') / .grantScoped('org-1', a, r)One permissionOnly some grants on an otherwise global role are restricted
Scoped assignment - engine.admin.assignRole(sub, role, 'org-1')The subject-to-role linkOne reusable role definition, different tenants per subject

Prefer scoped assignments whenever the permission shape is identical across tenants. Tenant-specific role IDs (org-acme-admin, org-globex-admin) work, but every permission change then has to be replicated across every tenant's copy.

There are really two mechanisms, and the first two rows above are both spellings of one of them. A declared scope is catalog data answering "where does this permission apply?"; an assignment scope is subject data answering "where does this subject hold this role?". They compose by intersection, so a role declaring org-a and assigned at org-b grants in neither: at org-b the role is held but its permission is declared for org-a, and at org-a the permission applies but the role is not held there.

declared scopeassignment scope
Lives inthe role catalogthe assignments table
'*' meansglobal - no scope condition emittedrefused on write; a stored one is the literal tenant "*"
'' meansan ordinary scope; validateRole rejects itrefused on write
undefined meansglobalglobal
Matched bythe scope condition in __rbac__enrichSubjectWithScopedRoles, literal comparison
Under scopeMode: 'hierarchical'the emitted condition widens to cover scope.*the match widens to every ancestor of the request scope

1. Role-level scope

const orgEditor = defineRole('org-editor')
  .name('Org Editor')
  .scope('org-1')
  .grant('publish', 'post')
  .build()

Every permission in the role inherits the scope. rolesToPolicy() appends one extra condition to each generated rule:

{
  "id": "__rbac__#6",
  "effect": "allow",
  "description": "Org Editor: publish on post",
  "priority": 10,
  "actions": ["publish"],
  "resources": ["post"],
  "conditions": {
    "all": [
      { "field": "subject.roles", "operator": "contains", "value": "org-editor" },
      { "field": "scope", "operator": "eq", "value": "org-1" }
    ]
  }
}

The rule now needs both facts: the subject holds org-editor, and the request carries scope: 'org-1'. A request with no scope resolves scope to null, the eq fails, and the rule does not match.

2. Permission-level scope

const hybrid = defineRole('hybrid')
  .name('Hybrid')
  .grant('read', 'post')                     // global
  .grant('update', 'post', 'org-1')          // org-1 only
  .grantScoped('org-2', 'create', 'comment') // org-2 only
  .build()

The two spellings produce the identical permission object; grantScoped just reads scope-first.

The scope actually written into the rule is perm.scope ?? owner.scope, where owner is the role that declared the permission - the permission wins, and its declaring role's scope is the fallback. If the resulting scope is '*', no scope condition is emitted at all, which is why a wildcard scope matches every request including unscoped ones:

defineRole('global-editor').grant('update', 'post', '*').build()
// generated rule conditions: only the subject.roles check

3. Scoped assignments

The same role definition, granted per tenant:

await engine.admin.assignRole('alice', 'viewer')            // global
await engine.admin.assignRole('alice', 'admin', 'org-acme') // org-acme only

const allowed = await engine.can(
  'alice',
  'delete',
  { type: 'post', attributes: {} },
  undefined,   // environment
  'org-acme',  // scope
)
assignRole(subjectId: string, roleId: TRole, scope?: TScope, opts?: IAssignOptions): Promise<void>
revokeRole(subjectId: string, roleId: TRole, scope?: TScope, opts?: IRevokeOptions): Promise<void>
updateAssignmentScope(
  subjectId: string,
  roleId: TRole,
  fromScope: TScope | undefined,
  toScope: TScope | undefined,
  actor?: string,
): Promise<void>

All three invalidate the subject's cache entry. updateAssignmentScope moves an assignment in one write on adapters that implement it and falls back to revoke-plus-assign otherwise; the emulation checks the grant exists first, because falling through on a false return created the grant for a subject who held nothing.

IAssignOptions carries startsAt, expiresAt, attributes and actor. The per-grant attributes surface as subject.scopedRoles[].attributes, distinct from the subject's own global bag.

'*' is not an assignment scope

'*' means "every scope" on the declared axis, so writing it on an assignment beside a role declared scope: '*' is the obvious move. On an assignment it means nothing of the kind, and the write path refuses it:

await engine.admin.assignRole('u1', 'admin', '*')
// Error: [@gentleduck/iam:engine] scope must not be "*"; a scoped assignment is
// matched literally, so this grant would be stored and answer only a request
// whose own scope is the string "*". Omit the scope for a global assignment.

Omitting the scope is how the contract spells global. Write assignRole('u1', 'admin').

The refusal exists because the alternative was a silent success. enrichSubjectWithScopedRoles compares the stored scope literally, so before the guard the row landed, assignRole resolved, admin.assignRoles reported ok: true, applied: 1, and getEffectiveRoles returned [] for every real scope and for the unscoped request. The one request it answered was one whose own scope was the string "*".

The guard (iamAssertAssignableScope) also refuses '', which five of the six adapters accepted with five different outcomes. Lookups are exempt, because a revoke addresses a row that already exists and an operator holding pre-guard '*' rows has to be able to delete them:

await engine.admin.revokeRole('u2', 'admin', '*')   // still works
CallIntent
assignRole, assignRoles rowsgrant - '*' and '' refused
revokeRole, revokeRoles rowslookup - '*' allowed, '' refused
updateAssignmentScope / moveRoleScopes fromScopelookup
updateAssignmentScope / moveRoleScopes toScopegrant

A move reads one scope and writes the other, so '*' may be moved off, never to. On the batch methods the check is a pre-pass over the whole list before any row is written, so a batch carrying one '*' row does not half-apply.

How a scope is matched at request time

Two independent things happen with a request's scope: it decides which scoped assignments merge into subject.roles, and it is the value the scope eq "..." conditions compare against.

Loading diagram...

  • getSubjectRoles() returns unscoped assignments only on every shipped adapter. Scoped grants come back separately from the optional getSubjectScopedRoles(). An adapter that collapses them would leak a tenant's role into global requests.
  • Both sets are closed over inherits (since 5.4.0). A scoped assignment of admin also brings in whatever admin inherits, so a condition reading subject.roles sees the same closure a global assignment would.
  • The merge is per request and never written back to the cached subject. The cached subject holds the global roles plus the untouched scopedRoles list; enrichment happens after the cache read, on every authorize(), can(), explain(), and each entry of permissions().
  • Inside the policy, scope is a resolver shorthand: resolve(request, 'scope') returns request.scope ?? null. That is what the emitted scope eq "org-1" conditions read.

engine.explain() reports the merge as scopedRolesApplied - the roles that were added by the request's scope - next to the subject's original roles, which makes "why did this role not apply" answerable in one call. See explain.

Flat and hierarchical matching

enrichSubjectWithScopedRoles() supports two matching modes, configured on the engine.

Loading diagram...

const engine = access.createEngine({
  adapter,
  scopeMode: 'hierarchical', // default 'flat'
  scopeCombine: 'union',     // default 'union', ignored under 'flat'
})
OptionValuesDefaultMeaning
scopeMode'flat' / 'hierarchical''flat''flat' requires an exact match. 'hierarchical' treats a dot-delimited scope as a path, so a grant at org-1 applies to org-1.team-2.repo-3.
scopeCombine'union' / 'override''union'Only consulted under 'hierarchical'. 'union' ORs in every matching level; 'override' keeps only the most specific level that has a grant, so a narrower grant shadows a broader one.

Hierarchical mode is safe to enable for apps that do not use dotted scopes: a scope with no dot has exactly one ancestor - itself - so it degrades to exact match. Hierarchical union is purely additive; there is no per-level revoke.

The prefix is a path segment, not a string prefix. org-10 is not under org-1, and org-a is not under org. enrichSubjectWithScopedRoles gets that by matching against scopeAncestors(scope), which cuts at each .; scopeCovers and the hierarchical rolesToPolicy condition get it by requiring scope + '.'.

override never grants anything union would not, but it can deny what union allows, and the shape that surprises people is inheritance-driven.

The engine's own scope walk is exported, so callers doing scope-aware rank or reach calculations use the same relation rather than reimplementing it:

import { iamScopeAncestors, iamScopeCovers } from '@gentleduck/iam'

iamScopeAncestors('org-1.team-2')                        // ['org-1.team-2', 'org-1']
iamScopeCovers('org-1', 'org-1.team-2', 'hierarchical')  // true
iamScopeCovers('org-1', 'org-1.team-2', 'flat')          // false

Combining global and scoped grants

await engine.admin.assignRole('alice', 'viewer')            // global
await engine.admin.assignRole('alice', 'admin', 'org-acme') // org-acme only
Request scopeEffective roles
'org-acme'['viewer', 'admin']
'org-other'['viewer'] - the scoped grant does not match
undefined['viewer'] - enrichment is skipped entirely when the request has no scope

This is the recommended shape for multi-tenant SaaS: one global "platform user" role, plus tenant-specific roles granted per organization.

Scope and inheritance

The scope written into a rule is perm.scope ?? owner.scope, where owner is the role that declared the permission - not the role the rule is emitted under. Flattening therefore does not narrow: IRole.scope is a default applied to that role's own permissions, never a ceiling on everything the role reaches. collectPermissions returns { owner, perm } pairs precisely so the declaring role travels with the permission, which is also what makes the interpreter and the compiled table agree.

const globalViewer = defineRole('global-viewer').name('Global Viewer').grant('read', 'post').build()

const orgEditor = defineRole('org-editor')
  .name('Org Editor')
  .scope('org-1')
  .inherits('global-viewer')
  .grant('update', 'post')
  .build()

Real output of rolesToPolicy([globalViewer, orgEditor]):

__rbac__#0  Global Viewer: read on post                    roles contains "global-viewer"
__rbac__#1  Org Editor: read on post (via Global Viewer)   roles contains "org-editor"
__rbac__#2  Org Editor: update on post                     roles contains "org-editor"  AND  scope eq "org-1"

org-editor declares scope: 'org-1', so its own update grant is confined to org-1. The read it inherits is not: global-viewer declares no scope, so rule #1 carries no scope condition and a subject holding only org-editor can read posts in every tenant and on an unscoped request. The (via Global Viewer) suffix in the description names the declaring role.

To confine an inherited permission, declare the scope on the role that declares the permission, put it on the permission itself, or scope the assignment instead of the role. Assigning org-editor at org-1 confines both grants, because there the assignment scope does the confining.

A permission that carries its own scope keeps it, because perm.scope wins over owner.scope. A permission granted with .grantScoped('org-2', ...) stays bound to org-2 however it is inherited.

For scoped assignments, inheritance is resolved before enrichment: a scoped grant of admin expands to admin plus everything admin inherits. The directly assigned role keeps the scope it was actually assigned at; every inherited role is re-tagged with its own IRole.scope, falling back to the assignment row's scope when it declares none. That retag matches how rolesToPolicy gates each role's rules, and it is where the surprising answers come from:

  • Cross-tenant reach. lead (no declared scope) inherits b-admin (declares org-b), assigned to u1 at org-a. Then getEffectiveRoles('u1', 'org-a') is ['lead'] and getEffectiveRoles('u1', 'org-b') is ['b-admin']. A grant made only in org-a produces an allow in org-b.
  • Upward escalation. A grant made only at org-a.team-1, of a role that inherits one declaring org-a, answers at org-a. This happens in flat mode too - it is the retag, not a hierarchy walk.
  • Subtree widening. An inherited role declaring a root scope (org) widens one grant at org-a to org and everything under org.* in hierarchical mode. org-a itself stays denied: it is not under org, because the separator is ., not -.
  • Diamonds carry per-path scope. An unscoped inner role reached through an org-a parent and an org-b parent grants in org-a, org-b and the assignment scope.

Exported helper

matchesScope(pattern, scope) owns the scope-matching contract. scopeCovers routes its exact-match arm through it rather than re-implementing ===, because the contract was once documented in resolve.ts and separately enforced by three unrelated expressions elsewhere, which is how the truth tables came to disagree.

import { matchesScope } from '@gentleduck/iam'

matchesScope(undefined, 'org-1') // true  - no pattern means global
matchesScope('*', undefined)     // true  - wildcard matches an unscoped request
matchesScope('org-1', 'org-1')   // true
matchesScope('org-1', 'org-2')   // false
matchesScope('org-1', undefined) // false - a scoped pattern needs a scoped request
matchesScope('', '')             // true  - '' is an ordinary scope value
matchesScope('', 'org-1')        // false

There is no recursion here: matchesScope never treats org-1 as covering org-1.team-2. That relation is iamScopeCovers.

Gotchas

  • A scoped role assigned globally still does nothing outside its scope - for the permissions it declares itself. IRole.scope puts a condition on the rules generated from that role's own permissions, independent of how it was assigned. Anything it inherits from an unscoped role stays unscoped.
  • '*' type-checks on a permission and not on a role. IPermission.scope is TScope | '*'; IRole.scope is TScope. Both are read as global at runtime, so under a narrowed TScope union the difference is only the type checker's. Declare the global marker on the permission, or leave IRole.scope off entirely - omitting it is the same thing at runtime.
  • Scope is not authentication. Nothing verifies that the caller may act in the scope they passed. Derive the scope from the authenticated session or a verified path parameter, never from an unvalidated request body. See production hardening.
  • getSubjectScopedRoles is optional on the adapter interface. An adapter that omits it never produces scoped roles, and enrichment is a no-op. Check adapter comparison before relying on scoped assignments.
  • Enrichment is skipped when the request has no scope, even under hierarchical mode. There is no "root" scope that matches everything.

See also