Skip to main content

Role inheritance

How inherits() resolves - parent-first flattening, diamond de-duplication, cycle cutting, the 32-level depth cap, and the validator codes that catch bad graphs

inherits() makes a role's permissions include every parent's permissions, resolved recursively. Two independent walks implement it: collectPermissions() flattens permissions when roles are converted to a policy, and resolveEffectiveRoles() closes a subject's assigned role IDs over the same graph. Both live in src/core/rbac/rbac.ts, both stop at MAX_INHERITANCE_DEPTH, and both carry the same memo.

That memo is a shallowest-depth map, not a visited set. Each role records the shallowest depth it was reached at, and a re-reach at the same or greater depth short-circuits. A plain visited set pins a role to whatever depth it happened to be reached at first, so a role reached near the depth cut down a long chain blocks a later, shallower path from expanding its ancestors - and then two set-equal inherits arrays written in different orders resolve to different permissions. The same memo cuts cycles: a inherits b inherits a terminates with ['a', 'b'].

A chain

import { defineRole } from '@gentleduck/iam'

const viewer = defineRole('viewer')
  .name('Viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()

const editor = defineRole('editor')
  .name('Editor')
  .inherits('viewer')
  .grant('create', 'post')
  .grant('update', 'post')
  .build()

const admin = defineRole('admin').name('Admin').inherits('editor').grantAll('*').build()

An admin holds everything an editor holds, which is everything a viewer holds, plus its own wildcard grant. Note that inherits() takes every parent in one call - a second call replaces the first (see defining roles).

The two walks

resolveEffectiveRoles(assignedRoles, allRoles) answers "which role IDs does this subject effectively hold?" and is what fills subject.roles. collectPermissions(roleId, rolesMap) answers "which permissions does this role end up with?" and runs once per role during conversion.

Loading diagram...

  • W2 is what makes the walk safe: a role re-reached at the same or greater depth stops there, so a cycle terminates and a diamond contributes once. A role re-reached shallower expands its ancestors again from the new depth, but emits its own permissions only once.
  • DROP and KEEP are the asymmetry. An inherits entry that no role defines never reaches subject.roles, because a role id there is not only a permission carrier - a hand-written ABAC rule subject.roles contains 'ghost' fires on it, and deleteRole cascades only the role's assignments, so a surviving inherits: ['ghost'] used to keep feeding the id back. validateRoles already calls that catalog state DANGLING_INHERIT with type: 'error'. Depth 0 - the subject's own assignment - is exempt: that is a row an operator wrote, and dropping it would narrow getEffectiveRoles wherever the catalog is not the sole authority on which ids exist. It matches no rule either way, because rolesToPolicy() emits nothing for a role it cannot find.
  • collectPermissions() follows the identical shape and returns [...inherited, ...ownPermissions] - parent-first order. It also returns { owner, perm } pairs: the role that declared each permission travels with it, which is what makes a declared scope belong to the declarer rather than the inheritor. See scoped roles.

engine.getEffectiveRoles(subjectId, scope?) exposes the resolved set for debugging; it uses the same subject cache as can().

Diamonds resolve once

Two parents that share a grandparent do not double the grandparent's permissions.

Loading diagram...

Converting these four roles produces nine rules, not ten. Walking top visits left first, which pulls in base's read; when the walk reaches right, base was already recorded at depth 2 and this reach is also depth 2, so it short-circuits and right contributes only its own delete. The rules attributed to top are exactly:

__rbac__#5  Top: read on post
__rbac__#6  Top: update on post
__rbac__#7  Top: delete on post
__rbac__#8  Top: publish on post

resolveEffectiveRoles(['top'], roles) returns ['top', 'left', 'base', 'right'] - depth-first, in declaration order, each ID once.

Cycles are cut, not rejected

If a inherits b and b inherits a, neither walk hangs or throws. The second time round the loop the role is re-reached at a greater depth than it was recorded at, and the memo short-circuits.

const a = defineRole('a').name('A').inherits('b').grant('read', 'post').build()
const b = defineRole('b').name('B').inherits('a').grant('write', 'post').build()

rolesToPolicy([a, b]).rules.map((r) => r.description)
// ['A: write on post', 'A: read on post', 'B: read on post', 'B: write on post']

Four rules, no duplicates, no runaway - pinned by the test "rolesToPolicy terminates on a cyclic inherits graph without duplicating permissions". Both roles converge on the same permission set, which is exactly what a cycle means. validateRoles() reports it as the warning CIRCULAR_INHERIT rather than an error, because the runtime is safe; it is still almost always a modelling mistake.

The depth cap

import { MAX_INHERITANCE_DEPTH } from '@gentleduck/iam'
// 32

Both walks bail once depth exceeds 32. Cycles are already handled by the memo; the cap exists for a long linear chain (or a malformed import) that would otherwise grow the call stack unboundedly and make traversal cost unpredictable. It is a single hard constant with no per-engine override, so every adapter and the validator agree on the same bound. A 1000-deep linear chain resolves to roughly 33 roles and does not blow the stack.

This is the same 32 as IAM_MAX_COMPILED_ROLES only by coincidence. That one bounds how many roles the compiled table's grant mask can address; this one bounds how deep an inherits chain is walked. They are unrelated numbers that happen to share a value.

validateRoles() reports a chain past the cap as the error INHERITANCE_TOO_DEEP. Run it in CI so a graph that grew past 32 fails before deploy rather than surfacing as a role list that no longer matches the verdicts.

What the validator catches

validateRoles() is the only check that sees the whole graph. Import it from the validate subpath:

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

const result = validateRoles([viewer, editor, admin])
for (const issue of result.issues) {
  console.log(`[${issue.type}] ${issue.code} ${issue.roleId ?? ''} - ${issue.message}`)
}
CodeSeverityMeaning
DUPLICATE_ROLE_IDerrorTwo roles in the set share an id.
DANGLING_INHERITerrorA role inherits from an ID that is not in the set.
CIRCULAR_INHERITwarningA cycle was reached while walking this role's ancestors.
EMPTY_ROLEwarningThe role has no permissions and no inherits.
INHERITANCE_TOO_DEEPerrorThe longest chain from this role exceeds MAX_INHERITANCE_DEPTH.

result.valid is true when no issue has type: 'error'; warnings do not flip it. access.validateRoles(roles) from createIam() is the same function with the role array typed to your declared unions.

Inherited permissions cannot be removed

There is no negative permission and no "except" clause. If editor inherits viewer, no role definition can take read away from an editor. Subtract with a deny rule in a separate policy:

import { definePolicy } from '@gentleduck/iam'

const restrictedEditor = defineRole('restricted-editor').inherits('editor').build()

const restriction = definePolicy('restrict-editor-deletes')
  .name('Restrict Editor Deletes')
  .target({ roles: ['restricted-editor'] })
  .algorithm('deny-overrides')
  .rule('no-delete', (r) => r.deny().on('delete').of('post'))
  .build()

Under the default policyCombine: 'and', a deny from any applicable policy is final, so this beats every allow rule __rbac__ produced. See combining algorithms and cross-policy combining.

Gotchas

  • Cost is paid at conversion, not per request. Flattening runs when the role set is loaded and the result is cached with the __rbac__ policy. Deep graphs make the policy bigger, not each check slower.
  • Flattening multiplies rules. A role at the bottom of a chain emits one rule for every permission it inherits, each gated on its own role ID. Ten roles of ten permissions in a chain of five is hundreds of rules in __rbac__; see rule count.
  • Prefer shallow graphs. Three or four levels stays legible. Deep chains hide where a permission originated, and the explain trace reports the role that emitted the rule, not the ancestor the permission came from.
  • Scope travels with the permission, not up the chain. A scoped role inheriting a global role keeps those inherited permissions global. See scoped roles.

See also