Skip to main content

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.

Loading diagram...

Building the Hierarchy

Add the editor role

The editor inherits from viewer and adds create/update permissions:

src/access.ts
export const editor = defineRole('editor')
  .inherits('viewer')
  .grant('create', 'post')
  .grant('update', 'post')
  .grant('create', 'comment')
  .grant('update', 'comment')
  .build()
src/access.ts
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):

src/access.ts
export const admin = defineRole('admin')
  .inherits('editor')
  .grant('delete', 'post')
  .grant('delete', 'comment')
  .grant('manage', 'user')
  .grant('manage', 'dashboard')
  .build()
src/access.ts
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

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

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

Loading diagram...

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 object
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 object
MethodDescription
.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()
ShortcutEquivalentDescription
.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 fieldScoped permission
.grantWhen(action, resource, conditions)Permission with conditionsConditional 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 with posts: (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

IssueCodeSeverityExample
Duplicate role IDsDUPLICATE_ROLE_IDerrorTwo roles both called 'editor'
Dangling inheritsDANGLING_INHERITerroreditor inherits 'reviewer' which does not exist
Circular inheritanceCIRCULAR_INHERITwarninga inherits b, b inherits a
Empty rolesEMPTY_ROLEwarningRole 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.

Checkpoint


Chapter 2 FAQ


Next: Chapter 3: Policies, Rules, and Conditions