Skip to main content

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.

Loading diagram...

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

MethodSignatureEffect
namename(n: string): thisSets 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.
descdesc(d: string): thisSets role.description. Documentation only.
metameta(m: IamPrimitives.Attributes): thisReplaces 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.

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

MethodSignatureExpands to
grantAllgrantAll(resource: TResource | '*'): thisgrant('*', resource) - every action on that resource. grantAll('*') is unrestricted access.
grantReadgrantRead(...resources: ('read' extends TAction ? TResource | '*' : never)[]): thisOne grant('read', r) per argument.
grantCRUDgrantCRUD(resource: IamCrudAction extends TAction ? TResource | '*' : never): thisFour 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.

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:

CodeRaised when
MISSING_FIELDid is not a non-empty string, permissions is not an array, or a permission is missing action or resource.
INVALID_TYPEThe 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.

Loading diagram...

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 // 1
    
  • Optional fields are absent, not undefined. description, inherits, scope and metadata are spread in conditionally, so 'scope' in role is false on a role that never called .scope(). This matters because the memory, file and http stores keep the caller's own object while a jsonb column or a JSON.stringify round trip drops an undefined key - a role holding the key read back unequal from two adapters.

  • name silently 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