Skip to main content

Roles overview

What a role is in duck-iam, the IRole and IPermission shapes, how roles become a synthetic ABAC policy, and which roles page answers which question

A role is a named bag of action/resource permissions with optional inheritance and an optional scope. duck-iam has no separate RBAC engine: rolesToPolicy() converts every role into ABAC rules that run through the same pipeline as hand-written policies, so roles and policies compose. This page is the map of the roles section and the reference for the two data shapes every other page builds on.

What a role is

import { defineRole } from '@gentleduck/iam'

const viewer = defineRole('viewer')
  .name('Viewer')
  .desc('Read-only access to published content')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()

defineRole(id) returns a RoleBuilder. .build() validates and returns a plain AccessControl.IRole - a JSON record with no methods, no engine reference, and nothing to serialise around. Store it with engine.admin.saveRole(role) or write it straight into an adapter.

When to use / When not to use

Use a role when the answer to "may this subject do this?" is a property of who the subject is. Use a policy when it is a property of the request: time, network, feature flag, or a relationship between two attributes that no role can encode.

  • Role - "editors may update posts", "auditors may read everything", "org admins manage billing in their org".
  • Role with a condition - "authors may update posts they own". The ownership check belongs to the role's meaning, so it goes on the role via grantWhen().
  • Policy - "nobody writes during maintenance mode", "deny requests from untrusted IPs", "block this specific subject". These span roles and have their own lifecycle.

Roles are additive by construction: the generated policy uses allow-overrides, so adding a role can only grant more. Nothing in the role model can take a permission away. To subtract, write a deny rule in a separate policy - see combining algorithms and cross-policy combining.

The two shapes

Copied from src/core/types/access-control.ts:

interface AccessControl.IRole<
  TAction extends string = string,
  TResource extends string = string,
  TId extends string = string,
  TScope extends string = string,
> {
  readonly id: TId
  readonly name: string
  readonly description?: string
  readonly permissions: readonly IPermission<TAction, TResource, TScope>[]
  /** Parent role IDs to inherit permissions from (resolved recursively). */
  readonly inherits?: readonly string[]
  /** Default scope applied to all permissions in this role. */
  readonly scope?: TScope
  readonly metadata?: Readonly<IamPrimitives.Attributes>
}

interface AccessControl.IPermission<
  TAction extends string = string,
  TResource extends string = string,
  TScope extends string = string,
> {
  readonly action: TAction | '*'
  readonly resource: TResource | '*'
  readonly scope?: TScope | '*'
  readonly conditions?: IConditionGroup
}
FieldTypeDefaultMeaning
idstringnone, requiredUnique role ID. Referenced by inherits, by assignments, and by the subject.roles contains "<id>" condition on every generated rule.
namestringthe idDisplay name. Also the prefix of each generated rule's description.
descriptionstring | undefinedundefinedDocumentation only; never read during evaluation.
permissionsIPermission[][]The grants. An empty list is valid and grants nothing.
inheritsstring[] | undefinedundefinedParent role IDs. undefined rather than [] when no parents were declared.
scopestring | undefinedundefinedDefault scope for every permission in the role.
metadataAttributes | undefinedundefinedArbitrary bookkeeping; never read during evaluation.

IamPrimitives.Attributes is Record<string, AttributeValue>, where AttributeValue is a scalar, an array of scalars, or a flat record of scalars. See primitives and types and namespaces.

How a role reaches a decision

Roles never bypass the policy engine. The diagram traces one engine.can() call from stored role rows to a decision.

Loading diagram...

  • B and C are the conversion documented on the rolesToPolicy conversion. Every permission becomes one allow rule gated by subject.roles contains "<roleId>".
  • E closes the assigned roles over inherits, which is what inheritance covers.
  • H merges scoped assignments whose scope matches the request; see scoped roles.
  • I and J are the shared pipeline described in evaluation and cross-policy combining. A deny from any applicable policy still wins over every allow __rbac__ produced.

Both the role list and the converted __rbac__ policy are cached in the engine and rebuilt when a role changes. See engine caching.

Which page do I need

Loading diagram...

PageCovers
Defining rolesEvery RoleBuilder method with its signature, what build() validates and throws, metadata, empty roles
Role inheritanceinherits(), multi-parent and diamond graphs, cycle handling, the depth-32 traversal bound and where getEffectiveRoles() and can() diverge past it
Type-safe rolescreateIam(), how TAction / TResource / TRole / TScope / TContext flow, per-resource attribute narrowing
Scoped rolesRole-level scope, permission-level scope, scoped assignments, and how a scope is matched at request time
Conditional permissionsgrantWhen(), how its conditions merge into the generated rule, and when a standalone policy is the better tool
The rolesToPolicy conversionThe conversion algorithm with a real dumped __rbac__ policy, rule id stability, cache invalidation

Gotchas

  • defineRole('x').build() throws if the role fails validateRole(). Structural problems that only appear across a set of roles (duplicate IDs, dangling inherits, cycles, chains past the depth-32 traversal bound) are found by validateRoles(roles), which you call yourself. See validation.
  • role.metadata is inert. Nothing in the evaluator reads it; only permissions, inherits, and scope affect decisions.
  • Role IDs appear verbatim inside generated rule conditions. Renaming a role means re-saving every role that inherits it and re-issuing every assignment that names it.

See also