Defining roles
The complete RoleBuilder API - every method with its verified signature, what build() validates and throws, metadata, and the aliasing and cast gotchas
defineRole(id) returns a RoleBuilder. Every method returns this, and build() produces a plain AccessControl.IRole. This page is the API reference for that builder: each signature is copied from src/core/builder/role.ts, with the defaults and the failure modes.
The builder in one call chain
import { defineRole } from '@gentleduck/iam'
const editor = defineRole('editor')
.name('Editor')
.desc('Full write access to posts and comments')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('delete', 'post')
.grantCRUD('comment')
.meta({ tier: 'staff' })
.build()
Each call sets one field of the built role. The diagram maps builder methods to the IRole fields they write.
ID, NAME, DESC, and META never affect a decision. PERMS, INH, and SCOPE are the three fields rolesToPolicy() reads.
API reference
defineRole()
const defineRole: <
const TRole extends string,
const TAction extends string = string,
const TResource extends string = string,
const TScope extends string = string,
TContext extends object = DotPath.IDefaultContext,
>(id: TRole) => RoleBuilder<TAction, TResource, TRole, TScope, TContext>
The const modifier on TRole preserves the literal type of the ID, so defineRole('viewer') is typed to 'viewer' and not widened to string. For builders constrained to your declared actions, resources, roles, and scopes, use access.defineRole() from createIam() instead of importing defineRole directly.
RoleBuilder is also exported and can be constructed directly (new RoleBuilder('viewer')); defineRole is the preferred spelling because it infers TRole for you.
Identity and documentation
| Method | Signature | Effect |
|---|---|---|
name | name(n: string): this | Sets role.name. Defaults to the role ID when never called. Used in admin dashboards, audit logs, and as the prefix of each generated rule's description. |
desc | desc(d: string): this | Sets role.description. Documentation only. |
meta | meta(m: IamPrimitives.Attributes): this | Replaces role.metadata. Never consulted during evaluation. |
const beta = defineRole('beta-tester')
.name('Beta Tester')
.desc('Early access to unreleased features')
.meta({ createdBy: 'system', maxSeats: 10, tier: 'beta' })
.grant('read', 'beta-feature')
.build()
console.log(beta.metadata) // { createdBy: 'system', maxSeats: 10, tier: 'beta' }
Metadata round-trips through every shipped adapter - memory, file, Redis, Prisma, and Drizzle each persist it as JSON. See adapters.
inherits()
inherits(...roleIds: (TRole | (string & {}))[]): this
Declares parent roles. The union with (string & {}) keeps autocomplete for declared role IDs while still accepting an ID that only exists in the database. inherits is left undefined on the built role when the method is never called, and only becomes an array when at least one parent was passed.
defineRole('editor').inherits('viewer').inherits('commenter', 'reporter') builds a role whose parents are ['commenter', 'reporter'] - viewer is gone. Pass every parent in one call. Pinned by the test "inherits() replaces rather than appends across calls".Semantics, cycles, and the depth cap are on role inheritance.
scope()
scope(s: TScope): this
Sets a default scope for every permission in the role. At conversion time each generated rule gains a scope eq "<s>" condition. To scope individual permissions instead, use grant()'s third argument or grantScoped(). See scoped roles.
grant()
grant(action: TAction | '*', resource: TResource | '*', scope?: TScope): this
The base grant. Pushes one IPermission. Passing '*' for either argument matches every action or every resource - the matcher is described in rule matching.
defineRole('viewer')
.grant('read', 'post') // { action: 'read', resource: 'post' }
.grant('update', 'post', 'org-1') // { action: 'update', resource: 'post', scope: 'org-1' }
The scope key is omitted from the permission object entirely when no scope is passed - it is not set to undefined. Adapters that diff stored JSON see no key at all. grant() tests scope !== undefined rather than truthiness, so grant('read', 'post', '') reaches the validator and throws; it used to take the falsy branch and produce a global permission.
grantScoped()
grantScoped(scope: TScope, action: TAction | '*', resource: TResource | '*'): this
Identical to grant(action, resource, scope) with the arguments reordered so the scope reads first. Use whichever spelling makes a mixed-scope role easier to scan:
const hybrid = defineRole('hybrid')
.grant('read', 'post') // global
.grantScoped('org-1', 'update', 'post') // org-1 only
.grantScoped('org-2', 'create', 'comment') // org-2 only
.build()
grantWhen()
grantWhen<R extends TResource | '*'>(
action: TAction | '*',
resource: R,
fn: (w: When<TAction, TResource, TRole, TScope, TContext, R>)
=> When<TAction, TResource, TRole, TScope, TContext, R>,
): this
Pushes a permission carrying a condition group. The callback receives a fresh When builder and everything added to it is combined with AND (buildAll()). The resource literal R narrows w.resourceAttr() when a typed context declares resourceAttributes. Full treatment on conditional permissions.
defineRole('author')
.grant('read', 'post')
.grantWhen('update', 'post', (w) => w.isOwner())
.build()
// permissions[1].conditions ===
// { all: [{ field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }] }
grantWhen() takes no scope argument. To scope a conditional permission, put the scope on the role with .scope().
Shorthand grants
| Method | Signature | Expands to |
|---|---|---|
grantAll | grantAll(resource: TResource | '*'): this | grant('*', resource) - every action on that resource. grantAll('*') is unrestricted access. |
grantRead | grantRead(...resources: ('read' extends TAction ? TResource | '*' : never)[]): this | One grant('read', r) per argument. |
grantCRUD | grantCRUD(resource: IamCrudAction extends TAction ? TResource | '*' : never): this | Four grants in this order: create, read, update, delete. |
defineRole('super-admin').grantAll('*').build()
// permissions === [{ action: '*', resource: '*' }]
defineRole('auditor').grantRead('post', 'comment', 'user', 'audit-log').build()
// four permissions, all action 'read'
defineRole('content-manager').grantCRUD('post').build()
// permissions.map(p => p.action) === ['create', 'read', 'update', 'delete']
grantCRUD() is deliberately narrower than grantAll(): it does not cover custom actions such as publish or archive.
'read', 'create', 'update', 'delete', so their parameter types collapse to never when those strings are not in your declared actions. Under actions: ['view'] as const, grantCRUD('post') is a compile error at the call site rather than four stored permissions naming actions no request can carry. Export IAM_CRUD_ACTIONS and spell your action list as [...IAM_CRUD_ACTIONS, 'publish'] to keep grantCRUD callable.build()
build(): AccessControl.IRole<TAction, TResource, TRole, TScope>
Assembles the role and runs validateRole() on it before returning (since 2.2.0), so a structurally broken role fails where it was written rather than inside an adapter write. On failure it throws:
[@gentleduck/iam:builder] RoleBuilder.build(): role rejected by validator - MISSING_FIELD at "id"
The message lists every error-level issue as CODE at "path", joined by ; . It names neither the role id nor the issue text, so read the id off the object you passed. validateRole() reads id, scope, permissions and inherits:
| Code | Raised when |
|---|---|
MISSING_FIELD | id is not a non-empty string, permissions is not an array, or a permission is missing action or resource. |
INVALID_TYPE | The role is not a plain object, inherits is present but not an array of strings, or a scope is '' at either the role or the permission level. Control characters in an id, action or resource are rejected here too - NUL is the redis assignment member separator, so a role carrying one stored fine and then threw on assignRole. |
It never reads name, and it accepts unknown keys on a role or a permission, so a misspelled scope or conditions on a permission validates clean and the grant is not the one you wrote.
Cross-role problems - duplicate IDs, dangling inherits, cycles, chains deeper than 32 - are not checked here. Run validateRoles(roles) over the whole set before deploying; see type-safe roles and validation.
V is automatic; VR is not. engine.admin.saveRole() re-runs validateRole() on the write path, so a role assembled by hand rather than by the builder is still checked before it reaches storage.
Empty roles
A role with no permissions builds successfully:
const placeholder = defineRole('placeholder').name('Placeholder').build()
// { id: 'placeholder', name: 'Placeholder', permissions: [] }
It grants nothing and contributes no rules to the generated policy. validateRoles() reports it as the warning EMPTY_ROLE - "Role "placeholder" has no permissions and no inheritance" - only when it also has no parents, because a role that exists purely to inherit is a legitimate alias. Warnings do not flip result.valid to false.
Gotchas
build()copies the builder's arrays, so a builder kept alive after a build can no longer push into a role that has already been validated and registered:const b = defineRole('x').grant('read', 'post') const role = b.build() b.grant('update', 'post') role.permissions.length // 1Optional fields are absent, not
undefined.description,inherits,scopeandmetadataare spread in conditionally, so'scope' in roleisfalseon a role that never called.scope(). This matters because the memory, file and http stores keep the caller's own object while ajsonbcolumn or aJSON.stringifyround trip drops anundefinedkey - a role holding the key read back unequal from two adapters.namesilently defaults to the ID. Generated rule descriptions read"<name>: <action> on <resource>", so unnamed roles produce descriptions like"editor: update on post"in explain traces.meta()replaces the whole metadata bag; there is no merge. Build the object once and pass it in a single call.
See also
- Role inheritance - what
inherits()actually resolves to - Conditional permissions - the
grantWhen()callback in depth - Scoped roles -
scope()versusgrantScoped() - Building policies - the sibling builder for ABAC policies