Skip to main content

Chapter 2: role hierarchies

Grow one role into a hierarchy with inheritance, grant shortcuts, and wildcards, then validate the whole set at startup

DocDuck needs more than a viewer. An editor writes documents, an admin manages teams and users. Rather than repeating permissions, each role inherits the one below it - and then you validate the result before the app serves a request.

Learning goals

  • Build an inheritance chain with .inherits() and know how it resolves.
  • Use grantCRUD, grantAll, grantRead, and grantScoped.
  • Read the wildcard matching rules exactly, including what '*' does not match.
  • Know the 32-level inheritance cap and how cycles are handled.
  • Validate a role set with validateRoles and interpret every issue code.

The hierarchy

Loading diagram...

editor inherits viewer, admin inherits editor. Carol holds only admin, but the engine resolves her effective roles to admin, editor, viewer and collects the permissions of all three. Inheritance points down the diagram, from admin to editor to viewer; permissions travel back up the same edges.

Building it

Extend src/roles.ts

src/roles.ts
import { defineRole } from '@gentleduck/iam'

export const viewer = defineRole('viewer')
  .name('Viewer')
  .desc('Read-only access to documents and teams')
  .grant('read', 'document')
  .grant('read', 'team')
  .build()

export const editor = defineRole('editor')
  .name('Editor')
  .desc('Writes documents')
  .inherits('viewer')
  .grant('create', 'document')
  .grant('update', 'document')
  .grant('share', 'document')
  .build()

export const admin = defineRole('admin')
  .name('Administrator')
  .desc('Manages teams and their members')
  .inherits('editor')
  .grant('delete', 'document')
  .grant('archive', 'document')
  .grant('manage', 'team')
  .grant('manage', 'user')
  .meta({ tier: 'staff' })
  .build()

export const roles = [viewer, editor, admin]

Validate at startup

validateRoles is not re-exported from the package root - it lives behind @gentleduck/iam/core/validate so services that never validate do not pay for the validator chunk.

src/roles.ts
import { validateRoles } from '@gentleduck/iam/core/validate'

const check = validateRoles(roles)
if (!check.valid) {
  throw new Error(check.issues.map((i) => `[${i.code}] ${i.message}`).join('; '))
}
for (const issue of check.issues) {
  if (issue.type === 'warning') console.warn(`[iam] ${issue.code}: ${issue.message}`)
}

Assign the roles

src/access.ts
import { IamEngine } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { roles } from './roles'

export const adapter = new IamMemoryAdapter({
  roles,
  assignments: {
    alice: ['viewer'],
    bob: ['editor'],
    carol: ['admin'],
  },
})

export const engine = new IamEngine({ adapter, mode: 'development' })

Check the inherited permissions

src/main.ts
import { engine } from './access'

const doc = { type: 'document', id: 'doc-1', attributes: {} }

async function main() {
  console.log(await engine.can('carol', 'read', doc))    // true  (from viewer)
  console.log(await engine.can('carol', 'update', doc))  // true  (from editor)
  console.log(await engine.can('carol', 'delete', doc))  // true  (own)
  console.log(await engine.can('bob', 'delete', doc))    // false (editor stops short)

  console.log(await engine.getEffectiveRoles('carol'))
  // [ 'admin', 'editor', 'viewer' ]
}

void main()

engine.getEffectiveRoles(subjectId, scope?) returns exactly the role list the engine resolved internally, through the same subject cache. It is the fastest way to confirm an inheritance chain is wired the way you think it is.

How inheritance resolves

Two separate walks happen, and they answer different questions.

Loading diagram...

resolveEffectiveRoles fills subject.roles. collectPermissions runs inside rolesToPolicy and emits the rules. Both use a visited set, so a cycle - a inherits b, b inherits a - is skipped rather than recursed into.

Multiple inheritance

A role can name several parents:

export const reviewer = defineRole('reviewer')
  .name('Reviewer')
  .inherits('viewer', 'commenter')
  .grant('archive', 'document')
  .build()

The reviewer gets everything from both parents plus its own grant. Diamonds are fine: if two parents both inherit viewer, the depth memo makes viewer contribute once.

The RoleBuilder API

defineRole('editor')
  .name('Editor')                     // display name; defaults to the role ID
  .desc('Writes documents')           // description, documentation only
  .inherits('viewer')                 // one or more parent role IDs
  .scope('team-acme')                 // role-level scope (chapter 5)
  .meta({ tier: 'staff' })            // arbitrary metadata, never evaluated
  .grant('create', 'document')        // one permission
  .grantScoped('team-acme', 'update', 'document')
  .grantWhen('update', 'document', (w) => w.isOwner())
  .grantAll('comment')                // '*' action
  .grantRead('document', 'team')      // 'read' on several resources
  .grantCRUD('document')              // create + read + update + delete
  .build()
MethodSignatureWhat it does
.name(n)(n: string) => thisDisplay name. Defaults to the role ID.
.desc(d)(d: string) => thisDescription. Never consulted at evaluation time.
.inherits(...ids)(...ids: string[]) => thisReplaces the parent list. Calling it twice does not append.
.scope(s)(s: TScope) => thisAdds scope eq s to every permission of the role.
.meta(m)(m: Attributes) => thisArbitrary metadata carried on the role record.
.grant(a, r, scope?)(action, resource, scope?) => thisOne permission. '*' is allowed for action and resource.
.grantScoped(s, a, r)(scope, action, resource) => thisSame, with the scope first. Mixes scoped and global grants in one role.
.grantWhen(a, r, fn)(action, resource, fn) => thisPermission guarded by a When condition group (chapter 3).
.grantAll(r)(resource) => thisShorthand for .grant('*', resource).
.grantRead(...rs)(...resources) => this.grant('read', r) for each.
.grantCRUD(r)(resource) => thiscreate, read, update, delete on one resource.
.build()() => AccessControl.IRoleValidates, then returns the plain record. Throws on error-level issues.

What a permission and a role look like

interface IPermission {
  readonly action: string | '*'
  readonly resource: string | '*'
  readonly scope?: string | '*'
  readonly conditions?: AccessControl.IConditionGroup
}

interface IRole {
  readonly id: string
  readonly name: string
  readonly description?: string
  readonly permissions: readonly IPermission[]
  readonly inherits?: readonly string[]
  readonly scope?: string
  readonly metadata?: Readonly<IamPrimitives.Attributes>
}

Both are plain data. .build() sets inherits to undefined rather than [] when no parents were declared, so a round trip through JSON stays stable.

Wildcards and matching

const superadmin = defineRole('superadmin').grant('*', '*').build()
const docAdmin = defineRole('doc-admin').grant('*', 'document').build()
const auditor = defineRole('auditor').grant('read', '*').build()

The matcher is deliberately small. There is no glob engine behind it.

PatternMatchesDoes not match
'*' (action or resource)anything-
'read'exactly readread:draft
'documents:*' (action)documents:create, documents:readdocuments, document:create
'document:*' (resource)document:draft, document:draft:v2document
'document.*' (resource)document.draft, document.draft.v2document
'document' (resource)exactly documentdocument.draft, document:draft

Validating the role set

import { validateRoles } from '@gentleduck/iam/core/validate'

const result = validateRoles(roles)
interface IResult {
  readonly valid: boolean            // false only when an error-level issue exists
  readonly issues: readonly IIssue[]
}

interface IIssue {
  readonly type: 'error' | 'warning'
  readonly code: IamValidate.ValidationCode
  readonly message: string
  readonly roleId?: string
  readonly path?: string
}
CodeSeverityFires when
DUPLICATE_ROLE_IDerrorTwo roles share an id.
DANGLING_INHERITerrorA role inherits an ID that is not in the set.
INHERITANCE_TOO_DEEPerrorA chain is deeper than 32; the runtime would silently drop the tail.
CIRCULAR_INHERITwarningA cycle exists. The runtime skips it, so it is survivable but almost always a bug.
EMPTY_ROLEwarningA role has no permissions and no parents.

valid is false only for error-level issues. Treat warnings as build failures in CI anyway - both of them describe configuration that does nothing useful.

What just happened

Carol's check for delete on document now walks a longer path than in chapter 1:

  1. resolveEffectiveRoles(['admin'], allRoles) returns ['admin', 'editor', 'viewer'] and that array becomes subject.roles.
  2. rolesToPolicy iterates the roles the adapter stores. For admin it collects the inherited permissions first (viewer's, then editor's) and then admin's own, emitting one allow rule for each - every one gated on subject.roles contains 'admin'.
  3. The delete on document rule matches, the allow-overrides algorithm inside __rbac__ returns allow, and there is no other policy to disagree with.

Bob's delete check fails at step 3: editor never emits a delete rule, so nothing in __rbac__ matches and the decision falls through to defaultEffect: 'deny'. Roles only ever emit allow rules; a role cannot deny.

Try it

  1. Add commenter (create and update on comment) and make editor inherit both viewer and commenter. Confirm getEffectiveRoles('bob') lists four roles.
  2. Introduce a cycle on purpose - viewer inherits admin - and run validateRoles. You get CIRCULAR_INHERIT as a warning and valid stays true. Then confirm engine.can still answers rather than hanging.
  3. Make auditor with .grant('read', '*') and assign it to a new subject. Confirm reads on document, team, and user all pass, but update on document does not.
  4. Try grant('read', 'document') and then check read on document.draft. It is denied. Change the grant to 'document.*' and check again.

Where we are

src/roles.ts holds three roles and the startup validation. src/access.ts seeds three subjects. Nothing yet can express "may Bob update this document" - that needs conditions, which is chapter 3.

See also