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, andgrantScoped. - 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
validateRolesand interpret every issue code.
The hierarchy
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
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.
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
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
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.
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.
MAX_INHERITANCE_DEPTH is 32. Anything past that depth is silently dropped at runtime, which is why validateRoles reports INHERITANCE_TOO_DEEP as an error: a chain that deep would lose permissions without any signal. In practice three to five levels is the readable limit.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()
| Method | Signature | What it does |
|---|---|---|
.name(n) | (n: string) => this | Display name. Defaults to the role ID. |
.desc(d) | (d: string) => this | Description. Never consulted at evaluation time. |
.inherits(...ids) | (...ids: string[]) => this | Replaces the parent list. Calling it twice does not append. |
.scope(s) | (s: TScope) => this | Adds scope eq s to every permission of the role. |
.meta(m) | (m: Attributes) => this | Arbitrary metadata carried on the role record. |
.grant(a, r, scope?) | (action, resource, scope?) => this | One permission. '*' is allowed for action and resource. |
.grantScoped(s, a, r) | (scope, action, resource) => this | Same, with the scope first. Mixes scoped and global grants in one role. |
.grantWhen(a, r, fn) | (action, resource, fn) => this | Permission guarded by a When condition group (chapter 3). |
.grantAll(r) | (resource) => this | Shorthand for .grant('*', resource). |
.grantRead(...rs) | (...resources) => this | .grant('read', r) for each. |
.grantCRUD(r) | (resource) => this | create, read, update, delete on one resource. |
.build() | () => AccessControl.IRole | Validates, 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.
| Pattern | Matches | Does not match |
|---|---|---|
'*' (action or resource) | anything | - |
'read' | exactly read | read:draft |
'documents:*' (action) | documents:create, documents:read | documents, document:create |
'document:*' (resource) | document:draft, document:draft:v2 | document |
'document.*' (resource) | document.draft, document.draft.v2 | document |
'document' (resource) | exactly document | document.draft, document:draft |
grant('read', 'document') does not grant read on document.draft. Prefix matching only happens when the pattern ends in :* or .*, and the separator in the pattern must match the separator in the request. Actions recognise :* only; resources recognise both. Chapter 5 uses this for hierarchical resources.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
}
| Code | Severity | Fires when |
|---|---|---|
DUPLICATE_ROLE_ID | error | Two roles share an id. |
DANGLING_INHERIT | error | A role inherits an ID that is not in the set. |
INHERITANCE_TOO_DEEP | error | A chain is deeper than 32; the runtime would silently drop the tail. |
CIRCULAR_INHERIT | warning | A cycle exists. The runtime skips it, so it is survivable but almost always a bug. |
EMPTY_ROLE | warning | A 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.
RoleBuilder.build() calls validateRole on the single role and throws with a message prefixed [@gentleduck/iam:builder] RoleBuilder.build(): role rejected by validator. What build() cannot see is the set: duplicates, dangling parents, and cycles are cross-role facts, which is what validateRoles is for.What just happened
Carol's check for delete on document now walks a longer path than in chapter 1:
resolveEffectiveRoles(['admin'], allRoles)returns['admin', 'editor', 'viewer']and that array becomessubject.roles.rolesToPolicyiterates the roles the adapter stores. Foradminit collects the inherited permissions first (viewer's, then editor's) and then admin's own, emitting one allow rule for each - every one gated onsubject.roles contains 'admin'.- The
deleteondocumentrule matches, theallow-overridesalgorithm 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
- Add
commenter(createandupdateoncomment) and makeeditorinherit bothviewerandcommenter. ConfirmgetEffectiveRoles('bob')lists four roles. - Introduce a cycle on purpose -
viewerinheritsadmin- and runvalidateRoles. You getCIRCULAR_INHERITas a warning andvalidstaystrue. Then confirmengine.canstill answers rather than hanging. - Make
auditorwith.grant('read', '*')and assign it to a new subject. Confirm reads ondocument,team, anduserall pass, butupdateondocumentdoes not. - Try
grant('read', 'document')and then checkreadondocument.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
- Role inheritance - cycle and diamond handling in detail
- rolesToPolicy - the generated policy, dumped
- Rule matching - the action and resource matchers
- Validation - every code the validator can emit
- Chapter 3: policies, rules, and conditions