The rolesToPolicy conversion
How role definitions become the synthetic __rbac__ policy - the algorithm step by step, a real dumped output, rule id stability, and cache invalidation
rolesToPolicy() is the bridge between RBAC and ABAC in duck-iam. It takes the full list of role definitions and returns one AccessControl.IPolicy in which every permission of every role has become an allow rule gated on holding that role. The engine calls it for you; understanding its output is what makes an explain trace readable.
API reference
import { rolesToPolicy, resolveEffectiveRoles, MAX_INHERITANCE_DEPTH, IAM_RBAC_POLICY_ID } from '@gentleduck/iam'
function rolesToPolicy(
roles: AccessControl.IRole[],
scopeMode: 'flat' | 'hierarchical' = 'flat',
): AccessControl.IPolicy
function resolveEffectiveRoles(assignedRoles: string[], allRoles: AccessControl.IRole[]): string[]
const MAX_INHERITANCE_DEPTH = 32
const IAM_RBAC_POLICY_ID = '__rbac__'
| Export | Takes | Returns |
|---|---|---|
rolesToPolicy | Every role definition, independent of who holds them, plus the engine's scopeMode | One policy: id: '__rbac__', name: 'RBAC Policies', description: 'Auto-generated from role definitions', algorithm: 'allow-overrides', one rule per flattened permission |
resolveEffectiveRoles | A subject's assigned role IDs plus every role definition | The closed set of role IDs: assigned plus inherited, de-duplicated. A directly assigned id the catalog does not define is kept; an inherited one is dropped |
MAX_INHERITANCE_DEPTH | - | The traversal bound both functions share, 32 |
IAM_RBAC_POLICY_ID | - | '__rbac__'. Exported because it is not a label: the evaluator has to tell this policy apart from an operator-authored one |
Both are pure. They take data, return data, and hold no engine reference - which is why they are usable in tests and build steps without an adapter.
The algorithm
Step by step, from src/core/rbac/rbac.ts:
- Index. Every role goes into a
Mapkeyed byid, soinheritslookups are constant time. - Flatten (
FLAT,ORDER).collectPermissions(roleId)walks theinheritsgraph and returns[...inherited, ...own]as{ owner, perm }pairs - the role that declared each permission travels with it, which is what makes a declared scope belong to the declarer. The memo records the shallowest depth each role was reached at, so cycles terminate and a diamond contributes once. Past depth 32 the walk returns an empty list. See role inheritance. - Build the base conditions (
COND). Alwayssubject.roles contains "<roleId>". Thenscope eq "<effectiveScope>"whereeffectiveScopeisperm.scope ?? owner.scope, unless it isundefinedor'*'. UnderscopeMode: 'hierarchical'that one condition becomes{ any: [scope eq S, scope starts_with "S."] }so a declared scope covers its descendants - without that arm, an assignment atorg-1reachedorg-1.team-awhile an identical role-declaredscope: 'org-1'did not, and one engine flag meant two different things. - Nest the permission's own conditions (
NEST). A permission with noconditionsgets{ all: [...base] }. One that has them gets{ all: [{ all: [...base] }, perm.conditions] }- the author's group is passed through whole, never enumerated, and the base conditions get their ownallwrapper so the author's group always sits one level down whatever its key is. Both details fixed silent bugs; see below. - Emit the rule (
RULE).effect: 'allow',priority: 10,actions: [perm.action],resources: [perm.resource], and anidfrom a monotonic counter. The description is"<role.name>: <action> on <resource>", with" (via <owner.name>)"appended when the permission was inherited. The"<role.name>: "prefix identifies the holder and is filtered on, so the inherited-from note goes after it. - Wrap (
WRAP). One policy,algorithm: 'allow-overrides', notargets, noversion.
Two things about step 4 that each closed a silent bug. Enumerating any/none by hand and falling through to [] for anything else dropped an unrecognised group - a typo'd key, a hand-edited row - and a conditional grant silently became unconditional; passing the group through whole lets the shared evalConditionGroup read an unknown group as false, so it fails closed instead. And splicing an all body in-line was depth-neutral while any/none had to be nested, so the identical condition tree crossed MAX_CONDITION_DEPTH in one shape and not the other - and the deeper shape then failed closed with no validation error, which for allow-only role permissions is a silent denial. The constant IAM_RBAC_CONDITION_DEPTH = 1 is the depth at which a permission's own group starts because of that wrapper; anything evaluating a permission's conditions outside the generated policy must start there, not at 0.
The counter is per call and resets on each invocation, so the same input always produces the same id sequence - pinned by the test "produces identical id sequence for identical input on repeated calls".
A real dump
Three roles, one plain, one inheriting with a conditional grant, one scoped:
import { defineRole, rolesToPolicy } from '@gentleduck/iam'
const viewer = defineRole('viewer')
.name('Viewer')
.desc('Read-only access to published content')
.grant('read', 'post')
.grant('read', 'comment')
.build()
const author = defineRole('author')
.name('Author')
.inherits('viewer')
.grant('create', 'post')
.grantWhen('update', 'post', (w) => w.isOwner())
.build()
const orgEditor = defineRole('org-editor').name('Org Editor').scope('org-1').grant('publish', 'post').build()
console.log(JSON.stringify(rolesToPolicy([viewer, author, orgEditor]), null, 2))
Actual output:
{
"id": "__rbac__",
"name": "RBAC Policies",
"description": "Auto-generated from role definitions",
"algorithm": "allow-overrides",
"rules": [
{
"id": "__rbac__#0",
"effect": "allow",
"description": "Viewer: read on post",
"priority": 10,
"actions": ["read"],
"resources": ["post"],
"conditions": {
"all": [{ "field": "subject.roles", "operator": "contains", "value": "viewer" }]
}
},
{
"id": "__rbac__#1",
"effect": "allow",
"description": "Viewer: read on comment",
"priority": 10,
"actions": ["read"],
"resources": ["comment"],
"conditions": {
"all": [{ "field": "subject.roles", "operator": "contains", "value": "viewer" }]
}
},
{
"id": "__rbac__#2",
"effect": "allow",
"description": "Author: read on post (via Viewer)",
"priority": 10,
"actions": ["read"],
"resources": ["post"],
"conditions": {
"all": [{ "field": "subject.roles", "operator": "contains", "value": "author" }]
}
},
{
"id": "__rbac__#3",
"effect": "allow",
"description": "Author: read on comment (via Viewer)",
"priority": 10,
"actions": ["read"],
"resources": ["comment"],
"conditions": {
"all": [{ "field": "subject.roles", "operator": "contains", "value": "author" }]
}
},
{
"id": "__rbac__#4",
"effect": "allow",
"description": "Author: create on post",
"priority": 10,
"actions": ["create"],
"resources": ["post"],
"conditions": {
"all": [{ "field": "subject.roles", "operator": "contains", "value": "author" }]
}
},
{
"id": "__rbac__#5",
"effect": "allow",
"description": "Author: update on post",
"priority": 10,
"actions": ["update"],
"resources": ["post"],
"conditions": {
"all": [
{ "all": [{ "field": "subject.roles", "operator": "contains", "value": "author" }] },
{ "all": [{ "field": "resource.attributes.ownerId", "operator": "eq", "value": "$subject.id" }] }
]
}
},
{
"id": "__rbac__#6",
"effect": "allow",
"description": "Org Editor: publish on post",
"priority": 10,
"actions": ["publish"],
"resources": ["post"],
"conditions": {
"all": [
{ "field": "subject.roles", "operator": "contains", "value": "org-editor" },
{ "field": "scope", "operator": "eq", "value": "org-1" }
]
}
}
]
}
Four things to read out of it:
#2and#3areviewer's permissions re-emitted underauthor. Flattening copies, it does not chain: each role's effective permission set is independently checkable in one pass, with no inheritance walk at evaluation time.(via Viewer)names the declaring role.#5nests thegrantWhen()group beside the base group rather than splicing it flat, so the author's group sits at a fixed depth whatever its key is.#6carries the role's scope asscope eq "org-1". Nothing distinguishes a role-level from a permission-level scope in the output;perm.scope ?? owner.scopecollapses both. Becauseowneris the declaring role,#2and#3would stay unscoped even ifauthordeclared a scope.- Keys that are
undefinedon the source role are absent from the JSON.metadatanever reaches the policy at all - it is not copied onto rules.
Rule ids are opaque
Since 2.0.0 the rule id is __rbac__#<N> where N is a monotonic counter over the whole conversion. The previous format interpolated role, action, and resource names, which produced ambiguous ids whenever any segment contained a ..
rule.id, so the shape is locked by a test, but the number carries no meaning: it is not stable across a change to the role set, and nothing about the role, action, or resource can be recovered from it. Use rule.description ("<role name>: <action> on <resource>") when you need to attribute a rule to a role in tooling or an audit log. A role with a dotted id such as org.admin still gets unique rule ids - pinned by the test "emits unique ids even when role / action / resource names contain dots".Why allow-overrides
allow-overrides means any matching allow rule decides the policy. That is the RBAC contract: holding more roles can only widen access, never narrow it, and a subject with two roles gets the union of their permissions rather than the intersection.
await engine.admin.assignRole('u-1', 'viewer')
await engine.admin.assignRole('u-1', 'commenter')
// either role granting the action is enough
Restriction lives outside __rbac__. Under the default policyCombine: 'and', a deny from any applicable policy is final, so a deny rule in one of your own policies overrides every allow the RBAC policy produced. See combining algorithms and cross-policy combining.
Where the engine calls it
The converted policy is cached, deep-frozen, and merged with your own policies:
loadRbacPolicy()builds it once per cache generation, single-flighted so a cold start under concurrency runs one conversion rather than N.loadAllPolicies()returns[rbacPolicy, ...yourPolicies]- the RBAC policy first. When it has no rules it is omitted entirely, so an installation with no roles pays nothing for it.- The compiled lookup table is built from the roles directly rather than from a pre-merged
__rbac__policy, and it produces the verdict in both modes. Development additionally runs the interpreter over the merged policy set, because the table cannot explain itself. See engine modes.
Q is the empty-role-set shortcut, FIRST is why __rbac__ appears at the head of an explain trace, and INV is the only path that rebuilds the conversion.
Invalidation is driven by engine.cache.invalidateRoles(roleId?), which engine.admin.saveRole() and deleteRole() call for you. It clears the role cache, the converted RBAC policy cache, and the merged-policy cache; then it evicts either every cached subject (when no roleId is given) or just the subjects holding that role, in roles or in scopedRoles. When an invalidator is configured the event is broadcast so every other engine instance does the same. Entries also expire on the configured cacheTTL. See engine caching.
Calling it yourself
You rarely need to, but it is exported and pure:
import { rolesToPolicy } from '@gentleduck/iam'
const rbac = rolesToPolicy([viewer, author, orgEditor])
console.log(rbac.rules.length) // 7
console.log(rbac.rules.filter((r) => r.description?.startsWith('Author:')).length) // 4
Useful for inspecting what a role set actually grants during debugging, asserting on generated rules in tests without an engine, and pre-computing the policy in a build step for a very large role table.
Why the rule count inflates
Rules are emitted per role and per inherited permission, so a chain of N roles each granting M permissions approaches N x M rules in the worst case. That is deliberate:
- Every rule is gated on
subject.roles contains "<roleId>", so the precomputed action/resource index lets the evaluator skip irrelevant rules without walking conditions. - Flattening at conversion time means no inheritance walk per request - each role's effective set is directly checkable.
- The cost is paid on policy load, which is cached, not per evaluation.
For very large role tables watch policy-load time, and know where the compiled table stops helping: IAM_MAX_COMPILED_ROLES is 32, because a role's bit position in the grant mask is 1 << index and JS bitwise operators wrap the shift amount mod 32, so role 32 would alias role 0's bit. Past 32 roles compileTable throws IamRoleLimitExceededError, the engine catches that error specifically, warns once, and both modes fall back to the interpreter for every subsequent request. Verdicts are unchanged; throughput is not. healthCheck() reports it:
const health = await engine.healthCheck()
health.compiledTable // { available: false, reason: 'role-limit-exceeded', roleCount: 41, limit: 32 }
The flag is latched for the life of the engine instance: deleting roles back under 32 and invalidating every cache does not restore the compiled table. Construct a new engine. See benchmarks.
See also
- Role inheritance - the flattening walk and its bounds
- Scoped roles - where the
scope eqcondition comes from - Evaluation pipeline - what happens to the policy once it is built
- Engine caching - the caches this conversion sits behind