Skip to main content

Type-safe roles

createIam() constrains role IDs, actions, resources, and scopes at compile time - how the type parameters reach RoleBuilder and where the guarantee stops

defineRole() imported from the package root accepts any string for every argument. createIam() fixes the action, resource, role, scope, and context types once, and hands back builders that reject anything outside them. This page shows what createIam() constrains, how the generics reach RoleBuilder, and the four places the compile-time guarantee does not hold.

createIam()

import { createIam } from '@gentleduck/iam'

const access = createIam({
  actions: ['create', 'read', 'update', 'delete', 'publish'] as const,
  resources: ['post', 'comment', 'user'] as const,
  scopes: ['org-1', 'org-2'] as const,
  roles: ['viewer', 'editor', 'admin'] as const,
})
function createIam<
  const TActions extends readonly string[],
  const TResources extends readonly string[],
  const TRoles extends readonly string[] = readonly string[],
  const TScopes extends readonly string[] = readonly string[],
  TContext extends object = DotPath.IDefaultContext,
>(
  input: IamConfig.IAccessConfigInput<TActions, TResources, TRoles, TScopes, TContext>,
): IamConfig.IAccessConfig<TActions[number], TResources[number], TRoles[number], TScopes[number], TContext>
Input optionTypeDefaultMeaning
actionsreadonly string[]requiredEvery action your app supports. Becomes TAction.
resourcesreadonly string[]requiredEvery resource type. Becomes TResource.
scopesreadonly string[][]Tenant / workspace identifiers. Becomes TScope.
rolesreadonly string[][]Role IDs. Becomes TRole.
contextobjectDotPath.IDefaultContextPhantom field for dot-path inference. The runtime value is never read - pass {} as unknown as AppContext.

as const is what makes this work. Without it TypeScript widens the array to string[], TActions[number] collapses to string, and every constraint silently disappears. There is no runtime error - the builders just stop rejecting typos.

The returned IamConfig.IAccessConfig carries the declared arrays back (access.actions, access.resources, access.scopes, access.roles - the last two are [] when not declared) plus eight factories: defineRole, definePolicy, defineRule, when, createEngine, checks, validateRoles, and validatePolicy.

How the generics reach RoleBuilder

Loading diagram...

  • IDX is the whole trick: a tuple declared as const indexed by number becomes the union of its members.
  • RB is where the order changes. createIam builds RoleBuilder<TAction, TResource, TRole, TScope, TContext>, but the standalone factory declares its own parameters as defineRole<TRole, TAction, TResource, TScope, TContext> so that TRole can be inferred from the argument. If you ever write the type arguments by hand, check which of the two you are calling.
  • GW threads the resource literal R into the When builder as TActiveResource, which is what powers per-resource attribute narrowing below.
  • RL is why w.role('admin') is checked: When.role() takes TRole, not string.

What is constrained

const viewer = access
  .defineRole('viewer')      // ok: 'viewer' is in roles
  .name('Viewer')
  .grant('read', 'post')     // ok
  .grant('read', 'comment')  // ok
  .build()

// access.defineRole('intern')            // error: not assignable to 'viewer' | 'editor' | 'admin'
// access.defineRole('viewer').grant('fly', 'post')     // error: 'fly' is not an action
// access.defineRole('viewer').grant('read', 'invoice') // error: 'invoice' is not a resource
// access.defineRole('viewer').grant('read', 'post', 'org-9') // error: 'org-9' is not a scope

The same unions reach every other builder from the same access object, so a role ID used in a policy target or a condition is checked too:

const ownerPolicy = access
  .definePolicy('owner-only')
  .algorithm('deny-overrides')
  .rule('admin-override', (r) =>
    r
      .allow()
      .on('*')
      .of('*')
      .when((w) => w.role('admin')), // ok: 'admin' is in roles
      // .when((w) => w.role('manager')) // error: not a declared role
  )
  .build()

Where the guarantee stops

grantRead() and grantCRUD() used to be a fifth hole - they cast straight past TAction, so a config declaring actions: ['view', 'edit'] still compiled a read grant that no request could match. Their parameter types are now conditional on the action union and collapse to never, so the mismatch is a compile error at the call site. Export IAM_CRUD_ACTIONS and spell the list as [...IAM_CRUD_ACTIONS, 'publish'] to keep grantCRUD callable.

Typed dot-paths

Path types come from src/core/types/dot-path.ts. DotPath.DotPaths<T> walks an object type and produces the union of every reachable path; DotPath.FlexibleDotPaths<T> is that union, widened with string when T contains an open index signature.

Loading diagram...

DotPath.IDefaultContext declares subject.attributes, resource.attributes, and environment as IAnyAttributes, which is an index signature - so the default is the OPEN branch. Declaring those bags concretely moves you to CLOSED, where a mistyped path fails to compile and the operand value is checked against the type at that path. $-references are typed the same way through DotPath.DollarPaths<TContext>; see $-variable references and typed context.

Per-resource attribute narrowing

Declare a resourceAttributes map on the context and grantWhen()'s third argument narrows w.resourceAttr() to the attributes of the resource you named:

const access = createIam({
  actions: ['read', 'update'] as const,
  resources: ['post', 'invoice'] as const,
  context: {} as unknown as {
    subject: { id: string; attributes: { tier: 'free' | 'pro' } }
    resourceAttributes: {
      post: { ownerId: string; status: 'draft' | 'published' }
      invoice: { customerId: string; amount: number }
    }
  },
})

const editor = access
  .defineRole('editor')
  .grantWhen('update', 'post', (w) => w.resourceAttr('status', 'eq', 'draft'))
  //                                     suggestions: 'ownerId' | 'status'
  .grantWhen('update', 'invoice', (w) => w.resourceAttr('amount', 'lt', 1000))
  //                                        suggestions: 'customerId' | 'amount'
  .build()

The mechanism is DotPath.ResolvedResourceAttrPaths<TContext, TActiveResource>: grantWhen<R> captures the resource literal as R and passes it to When as TActiveResource, which looks R up in the context's resourceAttributes map. '*' and any resource with no entry in the map fall back to the generic resource.attributes shape. The rule builder does the same through .of(); see rules.

Validating a role set

The compiler cannot see relationships between roles. Run the validator over the whole set before persisting:

const roles = [viewer, author, editor, admin]
const result = access.validateRoles(roles)

if (!result.valid) {
  for (const issue of result.issues) {
    if (issue.type === 'error') console.error(`${issue.code} on ${issue.roleId}: ${issue.message}`)
  }
  throw new Error('role set rejected')
}

access.validateRoles() is validateRoles() with the argument typed to your unions. Outside a createIam() setup, import it directly - it is not re-exported from the package root, because the validator is a separate chunk:

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

The five codes it can emit are tabulated on role inheritance. RoleBuilder.build() already runs the single-role validateRole() for you; validateRoles() adds everything that needs the whole set.

Complete example

import { createIam } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'

const access = createIam({
  actions: ['create', 'read', 'update', 'delete', 'publish', 'archive'] as const,
  resources: ['post', 'comment', 'user', 'settings'] as const,
  scopes: ['org-alpha', 'org-beta'] as const,
  roles: ['viewer', 'author', 'editor', 'org-admin', 'super-admin'] as const,
})

const viewer = access.defineRole('viewer').name('Viewer').grantRead('post', 'comment').build()

const author = access
  .defineRole('author')
  .name('Author')
  .inherits('viewer')
  .grant('create', 'post')
  .grantWhen('update', 'post', (w) => w.isOwner())
  .grantWhen('delete', 'post', (w) => w.isOwner())
  .grant('create', 'comment')
  .build()

const editor = access
  .defineRole('editor')
  .name('Editor')
  .inherits('author')
  .grant('update', 'post')
  .grant('delete', 'post')
  .grant('publish', 'post')
  .grant('archive', 'post')
  .build()

const orgAdmin = access.defineRole('org-admin').name('Organization Admin').inherits('editor').build()

const superAdmin = access.defineRole('super-admin').name('Super Admin').grantAll('*').build()

const roles = [viewer, author, editor, orgAdmin, superAdmin]
if (!access.validateRoles(roles).valid) throw new Error('role set rejected')

const engine = access.createEngine({ adapter: new IamMemoryAdapter(), mode: 'development' })
for (const role of roles) await engine.admin.saveRole(role)

access.createEngine() returns an IamEngine whose can(), authorize(), and permissions() arguments are constrained to the same unions, so a check for an action you never declared is a compile error too. See engine methods.

See also