chapter 2: role hierarchies
Build a role hierarchy with inheritance, assign multiple roles, use wildcards, grant shortcuts, and validate your configuration at startup.
Goal
BlogDuck needs more than viewers. You will create an editor who can create and update posts, and an admin who can do everything. Instead of duplicating permissions, you will use inheritance so each role builds on the one below it.
Building the Hierarchy
Add the editor role
The editor inherits from viewer and adds create/update permissions:
export const editor = defineRole('editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()export const editor = defineRole('editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()Because editor inherits viewer, an editor automatically gets read:post and
read:comment without listing them again.
Add the admin role
The admin inherits from editor (which inherits from viewer):
export const admin = defineRole('admin')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.grant('manage', 'user')
.grant('manage', 'dashboard')
.build()export const admin = defineRole('admin')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.grant('manage', 'user')
.grant('manage', 'dashboard')
.build()Admin gets viewer + editor + admin permissions -- three levels of inheritance.
Register all roles and assign users
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
})
export const engine = new Engine({ adapter })const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
})
export const engine = new Engine({ adapter })Test the hierarchy
import { engine } from './access'
async function main() {
// Viewer: can read, cannot create
console.log('alice read post:', await engine.can('alice', 'read', { type: 'post', attributes: {} }))
// true
console.log('alice create post:', await engine.can('alice', 'create', { type: 'post', attributes: {} }))
// false
// Editor: can read (inherited) + create
console.log('bob read post:', await engine.can('bob', 'read', { type: 'post', attributes: {} }))
// true
console.log('bob create post:', await engine.can('bob', 'create', { type: 'post', attributes: {} }))
// true
console.log('bob delete post:', await engine.can('bob', 'delete', { type: 'post', attributes: {} }))
// false
// Admin: can do everything
console.log('charlie delete post:', await engine.can('charlie', 'delete', { type: 'post', attributes: {} }))
// true
console.log('charlie manage user:', await engine.can('charlie', 'manage', { type: 'user', attributes: {} }))
// true
}
main()import { engine } from './access'
async function main() {
// Viewer: can read, cannot create
console.log('alice read post:', await engine.can('alice', 'read', { type: 'post', attributes: {} }))
// true
console.log('alice create post:', await engine.can('alice', 'create', { type: 'post', attributes: {} }))
// false
// Editor: can read (inherited) + create
console.log('bob read post:', await engine.can('bob', 'read', { type: 'post', attributes: {} }))
// true
console.log('bob create post:', await engine.can('bob', 'create', { type: 'post', attributes: {} }))
// true
console.log('bob delete post:', await engine.can('bob', 'delete', { type: 'post', attributes: {} }))
// false
// Admin: can do everything
console.log('charlie delete post:', await engine.can('charlie', 'delete', { type: 'post', attributes: {} }))
// true
console.log('charlie manage user:', await engine.can('charlie', 'manage', { type: 'user', attributes: {} }))
// true
}
main()How Inheritance Resolution Works
The engine calls resolveEffectiveRoles() which walks the inheritance chain recursively.
It maintains a visited set to prevent infinite loops if there is circular inheritance
(A inherits B, B inherits A). Circular inheritance does not crash -- it is just skipped.
The resolved roles for Charlie: ['admin', 'editor', 'viewer']. All three roles' permissions
are included in the __rbac__ policy evaluation.
Multiple Inheritance
A role can inherit from multiple parents. Let us add a moderator role that combines
viewer permissions with comment management:
const commenter = defineRole('commenter')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
const moderator = defineRole('moderator')
.inherits('viewer', 'commenter')
.grant('delete', 'comment')
.build()const commenter = defineRole('commenter')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
const moderator = defineRole('moderator')
.inherits('viewer', 'commenter')
.grant('delete', 'comment')
.build()The moderator gets read:post + read:comment (from viewer) + create:comment +
update:comment (from commenter) + delete:comment (its own). Duplicate permissions
are deduplicated automatically.
The Complete RoleBuilder API
The defineRole() function returns a RoleBuilder with these methods:
Core Methods
defineRole('editor')
.name('Content Editor') // human-readable name (defaults to the role ID)
.desc('Can create and edit content') // description
.inherits('viewer', 'commenter') // inherit from one or more parent roles
.scope('acme') // restrict this role to a scope (Chapter 5)
.meta({ department: 'content' }) // attach arbitrary metadata
.grant('create', 'post') // grant a permission
.build() // produce the Role objectdefineRole('editor')
.name('Content Editor') // human-readable name (defaults to the role ID)
.desc('Can create and edit content') // description
.inherits('viewer', 'commenter') // inherit from one or more parent roles
.scope('acme') // restrict this role to a scope (Chapter 5)
.meta({ department: 'content' }) // attach arbitrary metadata
.grant('create', 'post') // grant a permission
.build() // produce the Role object| Method | Description |
|---|---|
.name(n) | Set a human-readable display name |
.desc(d) | Set a description |
.inherits(...ids) | Inherit permissions from parent roles |
.scope(s) | Restrict all permissions to a specific scope (Chapter 5) |
.meta(m) | Attach arbitrary metadata (e.g., { color: 'blue' }) |
.grant(action, resource) | Grant a single permission |
.build() | Produce the immutable Role object |
Grant Shortcuts
Instead of writing multiple .grant() calls, use shortcuts:
// Grant all CRUD actions on a resource
defineRole('post-manager')
.grantCRUD('post')
// Equivalent to:
// .grant('create', 'post')
// .grant('read', 'post')
// .grant('update', 'post')
// .grant('delete', 'post')
.build()
// Grant all actions on a resource (wildcard)
defineRole('post-superuser')
.grantAll('post')
// Equivalent to: .grant('*', 'post')
.build()
// Grant read access to multiple resources
defineRole('reader')
.grantRead('post', 'comment', 'user')
// Equivalent to:
// .grant('read', 'post')
// .grant('read', 'comment')
// .grant('read', 'user')
.build()
// Grant a permission scoped to a specific tenant
defineRole('org-editor')
.grantScoped('acme', 'create', 'post')
.grantScoped('acme', 'update', 'post')
// These permissions only apply when scope is 'acme'
.build()
// Grant with conditions (Chapter 3 preview)
defineRole('self-editor')
.grantWhen('update', 'post', w => w.isOwner())
// This permission only applies when the user owns the resource
.build()// Grant all CRUD actions on a resource
defineRole('post-manager')
.grantCRUD('post')
// Equivalent to:
// .grant('create', 'post')
// .grant('read', 'post')
// .grant('update', 'post')
// .grant('delete', 'post')
.build()
// Grant all actions on a resource (wildcard)
defineRole('post-superuser')
.grantAll('post')
// Equivalent to: .grant('*', 'post')
.build()
// Grant read access to multiple resources
defineRole('reader')
.grantRead('post', 'comment', 'user')
// Equivalent to:
// .grant('read', 'post')
// .grant('read', 'comment')
// .grant('read', 'user')
.build()
// Grant a permission scoped to a specific tenant
defineRole('org-editor')
.grantScoped('acme', 'create', 'post')
.grantScoped('acme', 'update', 'post')
// These permissions only apply when scope is 'acme'
.build()
// Grant with conditions (Chapter 3 preview)
defineRole('self-editor')
.grantWhen('update', 'post', w => w.isOwner())
// This permission only applies when the user owns the resource
.build()| Shortcut | Equivalent | Description |
|---|---|---|
.grantCRUD(resource) | .grant('create/read/update/delete', resource) | All CRUD operations |
.grantAll(resource) | .grant('*', resource) | All actions (wildcard) |
.grantRead(...resources) | .grant('read', each) | Read on multiple resources |
.grantScoped(scope, action, resource) | Permission with scope field | Scoped permission |
.grantWhen(action, resource, conditions) | Permission with conditions | Conditional grant |
The Permission Object
Each .grant() call creates a Permission object inside the role:
interface Permission {
action: string | '*' // the action this permission grants
resource: string | '*' // the resource type
scope?: string | '*' // optional: restrict to a scope
conditions?: ConditionGroup // optional: conditions that must pass
}interface Permission {
action: string | '*' // the action this permission grants
resource: string | '*' // the resource type
scope?: string | '*' // optional: restrict to a scope
conditions?: ConditionGroup // optional: conditions that must pass
}A simple .grant('read', 'post') creates { action: 'read', resource: 'post' }. The
scope and conditions fields are added by .grantScoped() and .grantWhen().
The Role Object
After calling .build(), you get a plain Role object:
interface Role {
id: string // unique identifier
name: string // human-readable name
description?: string // optional description
permissions: Permission[] // array of granted permissions
inherits?: string[] // parent role IDs
scope?: string // optional role-level scope
metadata?: Attributes // optional arbitrary metadata
}interface Role {
id: string // unique identifier
name: string // human-readable name
description?: string // optional description
permissions: Permission[] // array of granted permissions
inherits?: string[] // parent role IDs
scope?: string // optional role-level scope
metadata?: Attributes // optional arbitrary metadata
}This object is serializable -- you can store it in a database, send it over HTTP, or log it.
Wildcards
Use '*' to match any action or resource:
// Full access to everything
const superadmin = defineRole('superadmin')
.grant('*', '*')
.build()
// All actions on posts only
const postManager = defineRole('post-manager')
.grant('*', 'post')
.build()
// Read access to everything
const auditor = defineRole('auditor')
.grant('read', '*')
.build()// Full access to everything
const superadmin = defineRole('superadmin')
.grant('*', '*')
.build()
// All actions on posts only
const postManager = defineRole('post-manager')
.grant('*', 'post')
.build()
// Read access to everything
const auditor = defineRole('auditor')
.grant('read', '*')
.build()Hierarchical Wildcards
Actions and resources support colon-based hierarchy patterns:
// Grant all post-related actions
defineRole('post-admin')
.grant('posts:*', 'post') // matches posts:create, posts:read, etc.
.build()
// Grant access to org and all sub-resources
defineRole('org-viewer')
.grant('read', 'org') // also matches org:project, org:project:doc
.build()// Grant all post-related actions
defineRole('post-admin')
.grant('posts:*', 'post') // matches posts:create, posts:read, etc.
.build()
// Grant access to org and all sub-resources
defineRole('org-viewer')
.grant('read', 'org') // also matches org:project, org:project:doc
.build()The matching rules:
'*'matches any value'posts:*'matches any action starting withposts:(e.g.,posts:create,posts:read)'org'as a resource also matches'org:project'and'org:project:doc'(hierarchical)
Dot-based resource hierarchies (dashboard.users) are covered in Chapter 5.
Validating Roles
Before running your app, validate your role configuration to catch mistakes:
import { validateRoles } from '@gentleduck/iam'
const result = validateRoles([viewer, editor, admin])
if (!result.valid) {
throw new Error('Role config error: ' + result.issues.map(i => i.message).join(', '))
}import { validateRoles } from '@gentleduck/iam'
const result = validateRoles([viewer, editor, admin])
if (!result.valid) {
throw new Error('Role config error: ' + result.issues.map(i => i.message).join(', '))
}ValidationResult and ValidationIssue
interface ValidationResult {
valid: boolean // true if no error-level issues
issues: ValidationIssue[] // all issues found
}
interface ValidationIssue {
type: 'error' | 'warning' // errors prevent startup, warnings are informational
code: string // machine-readable code
message: string // human-readable description
roleId?: string // which role caused the issue
path?: string // field path (if applicable)
}interface ValidationResult {
valid: boolean // true if no error-level issues
issues: ValidationIssue[] // all issues found
}
interface ValidationIssue {
type: 'error' | 'warning' // errors prevent startup, warnings are informational
code: string // machine-readable code
message: string // human-readable description
roleId?: string // which role caused the issue
path?: string // field path (if applicable)
}What It Catches
| Issue | Code | Severity | Example |
|---|---|---|---|
| Duplicate role IDs | DUPLICATE_ROLE_ID | error | Two roles both called 'editor' |
| Dangling inherits | DANGLING_INHERIT | error | editor inherits 'reviewer' which does not exist |
| Circular inheritance | CIRCULAR_INHERIT | warning | a inherits b, b inherits a |
| Empty roles | EMPTY_ROLE | warning | Role with no permissions and no inheritance |
valid is false only when there are error-level issues. Warnings are informational --
the engine will still work (cycles are skipped, empty roles do nothing).
Always validate at startup -- it is cheap and prevents silent failures at runtime.
Checkpoint
Full src/access.ts
import { defineRole, Engine, validateRoles } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
export const viewer = defineRole('viewer')
.name('Viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()
export const editor = defineRole('editor')
.name('Editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
export const admin = defineRole('admin')
.name('Administrator')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.grant('manage', 'user')
.grant('manage', 'dashboard')
.build()
// Validate at startup
const roleCheck = validateRoles([viewer, editor, admin])
if (!roleCheck.valid) {
throw new Error(roleCheck.issues.map(i => `[${i.code}] ${i.message}`).join(', '))
}
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
})
export const engine = new Engine({ adapter })import { defineRole, Engine, validateRoles } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
export const viewer = defineRole('viewer')
.name('Viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()
export const editor = defineRole('editor')
.name('Editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
export const admin = defineRole('admin')
.name('Administrator')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.grant('manage', 'user')
.grant('manage', 'dashboard')
.build()
// Validate at startup
const roleCheck = validateRoles([viewer, editor, admin])
if (!roleCheck.valid) {
throw new Error(roleCheck.issues.map(i => `[${i.code}] ${i.message}`).join(', '))
}
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
})
export const engine = new Engine({ adapter })Chapter 2 FAQ
Is there a limit on inheritance depth?
There is no hard limit. The engine uses a visited-set to prevent cycles, so even very deep chains are safe. In practice, 3-5 levels is common. Deeper chains become hard to understand for humans, which is a bigger problem than any technical limit.
Can a child role remove a permission from its parent?
No. RBAC inheritance is additive -- a child always has at least the parent's permissions plus its own. To restrict specific actions, use ABAC policies with deny rules (Chapter 3). For example, an editor who cannot delete uses the editor role for grants and a policy for the deny restriction.
Can a user have multiple roles directly?
Yes. The assignments map to an array of role IDs:
'alice': ['viewer', 'commenter']. The engine resolves permissions from all assigned roles
and their ancestors, deduplicates them, and combines them with allow-overrides. If any
role grants a permission, the RBAC layer allows it.
Is the wildcard dangerous?
grant('*', '*') gives full access to everything from RBAC. ABAC policies can still deny
specific actions (Chapter 3), so it is not necessarily a bypass. But use it carefully --
typically only for superadmin roles. A policy with deny-overrides can override even
wildcard RBAC grants.
When should I call validateRoles()?
At application startup, before handling any requests. It is a synchronous call that runs instantly. Add it to your initialization code alongside engine creation. Do not skip it in production -- it catches typos that would otherwise silently fail at runtime.
When should I use grantCRUD vs individual grants?
Use grantCRUD(resource) when a role needs all four CRUD operations on a resource.
It generates create, read, update, delete permissions. Use individual .grant()
calls when a role only needs some operations, or when your actions are not standard CRUD
(e.g., manage, publish, approve). Both approaches produce identical Permission
objects.
What is role metadata used for?
The .meta() method attaches arbitrary key-value data to a role. It has no effect on
authorization -- the engine ignores it. Use it for your application: display names,
colors, icons, sort order, feature flags, or any data you want to associate with a role.
It is stored alongside the role in the adapter.
What does .scope() on a role do?
Setting .scope('acme') on a role restricts ALL its permissions to the acme scope.
The permissions only match when the request has scope: 'acme'. This is different from
.grantScoped() which scopes individual permissions. Role-level scoping is a shorthand
for scoping every permission in the role. It is covered in detail in Chapter 5.