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. Add an editor who can create and update posts, and an admin who can do everything. 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, the editor 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 across three inheritance levels.
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 }) // mode defaults to 'development'const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
})
export const engine = new Engine({ adapter }) // mode defaults to 'development'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(), walking the inheritance chain recursively
with a visited set to break circular inheritance (A inherits B, B inherits A). Cycles
are skipped, not raised as errors.
Charlie's resolved roles: ['admin', 'editor', 'viewer']. All three roles'
permissions land in the __rbac__ policy.
Multiple Inheritance
A role can inherit from several parents:
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 and read:comment from viewer, create:comment and
update:comment from commenter, plus its own delete:comment. Duplicates are
deduplicated automatically.
The Complete RoleBuilder API
defineRole() 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
// 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
}.grant('read', 'post') produces { action: 'read', resource: 'post' }. Add scope
with an optional third argument to .grant() (e.g. .grant('read', 'post', 'org-1'))
or with .grantScoped(). Add conditions with .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
}It's serializable — 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
Validate your role configuration at startup to catch mistakes early:
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 still works (cycles are skipped, empty roles do nothing).
Validate at startup. It is cheap and prevents silent failures at runtime.