type safe roles
createAccessConfig() constraints on actions, resources, scopes, and roles. validateRoles() pre-flight checks.
createAccessConfig basics
createAccessConfig() returns typed builders. With roles declared, the role ID parameter on defineRole() and the .role() / .roles() methods on conditions are constrained to those values.
import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
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,
})import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
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,
})as const is required — without it, TypeScript widens the arrays to string[] and you lose the constraint.
What gets constrained
// These builders are fully typed -- invalid actions/resources/roles are compile errors
const viewer = access
.defineRole('viewer') // ok -- 'viewer' is in roles
.name('Viewer')
.grant('read', 'post')
.grant('read', 'comment')
// .grant('fly', 'post') // TS error: 'fly' is not in actions
// access.defineRole('intern') // TS error: 'intern' is not in roles
.build()
// Role checks in conditions are also typed
const ownerPolicy = access
.policy('owner-only')
.rule('admin-override', (r) =>
r
.allow()
.on('*')
.of('*')
.when((w) => w.role('admin')) // ok -- 'admin' is in roles
// .when(w => w.role('manager')) // TS error: 'manager' is not in roles
)
.build()// These builders are fully typed -- invalid actions/resources/roles are compile errors
const viewer = access
.defineRole('viewer') // ok -- 'viewer' is in roles
.name('Viewer')
.grant('read', 'post')
.grant('read', 'comment')
// .grant('fly', 'post') // TS error: 'fly' is not in actions
// access.defineRole('intern') // TS error: 'intern' is not in roles
.build()
// Role checks in conditions are also typed
const ownerPolicy = access
.policy('owner-only')
.rule('admin-override', (r) =>
r
.allow()
.on('*')
.of('*')
.when((w) => w.role('admin')) // ok -- 'admin' is in roles
// .when(w => w.role('manager')) // TS error: 'manager' is not in roles
)
.build()access.defineRole(), access.policy(), access.defineRule(), and access.when() return builders constrained to the declared actions, resources, scopes, and roles. Types flow through the chain — from grantWhen() callbacks to whenAny() conditions.
Validating roles
Before saving roles to your adapter, validate them for common mistakes:
import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['read', 'write'] as const,
resources: ['post'] as const,
})
const roles = [viewer, editor, admin]
const result = access.validateRoles(roles)
if (!result.valid) {
console.error('Role validation errors:')
for (const issue of result.issues) {
console.error(` [${issue.type}] ${issue.message}`)
}
}import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['read', 'write'] as const,
resources: ['post'] as const,
})
const roles = [viewer, editor, admin]
const result = access.validateRoles(roles)
if (!result.valid) {
console.error('Role validation errors:')
for (const issue of result.issues) {
console.error(` [${issue.type}] ${issue.message}`)
}
}The validator checks for:
- Duplicate role IDs
- Dangling
inheritsreferences (inheriting from a role that doesn't exist) - Inheritance cycles (A inherits B, B inherits A)
- Empty permission lists
Issues come in two severity levels:
error— blocking (the model is broken)warning— suspicious but survivable (e.g. cycles, empty roles)
Edge cases
- Empty permissions — a role with no permissions and no inheritance is valid but grants nothing. Flagged as a warning.
- Deep inheritance chains — resolved by walking the tree recursively, with a visited set to break cycles. Performance is linear in role count — 10-level chains resolve in microseconds.
- Removing inherited permissions — not supported. Use a deny rule instead. See inheritance.
- Wildcard scope —
scope: '*'matches every scope, including unscoped requests. Use for global permissions.
Per-resource attribute narrowing
Pass a context phantom field to enable resource-aware autocomplete in grantWhen():
const access = createAccessConfig({
actions: ['read', 'update'] as const,
resources: ['post', 'invoice'] as const,
context: {} 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'), // editor autocomplete: 'ownerId' | 'status'
)
.grantWhen('update', 'invoice', (w) =>
w.resourceAttr('amount', 'lt', 1000), // editor autocomplete: 'customerId' | 'amount'
)
.build()const access = createAccessConfig({
actions: ['read', 'update'] as const,
resources: ['post', 'invoice'] as const,
context: {} 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'), // editor autocomplete: 'ownerId' | 'status'
)
.grantWhen('update', 'invoice', (w) =>
w.resourceAttr('amount', 'lt', 1000), // editor autocomplete: 'customerId' | 'amount'
)
.build()The same builder calls produce different autocomplete suggestions based on which resource you're targeting. See the type-safe config docs for the full schema.
Complete example
import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
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')
.grantCRUD('comment')
.build()
const orgAdmin = access
.defineRole('org-admin')
.name('Organization Admin')
.inherits('editor')
.grantCRUD('user')
.grantCRUD('settings')
.build()
const superAdmin = access.defineRole('super-admin').name('Super Admin').grantAll('*').build()
// Validate before saving
const validation = access.validateRoles([viewer, author, editor, orgAdmin, superAdmin])
console.log(validation.valid) // true
// Save to adapter
for (const role of [viewer, author, editor, orgAdmin, superAdmin]) {
await engine.admin.saveRole(role)
}import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
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')
.grantCRUD('comment')
.build()
const orgAdmin = access
.defineRole('org-admin')
.name('Organization Admin')
.inherits('editor')
.grantCRUD('user')
.grantCRUD('settings')
.build()
const superAdmin = access.defineRole('super-admin').name('Super Admin').grantAll('*').build()
// Validate before saving
const validation = access.validateRoles([viewer, author, editor, orgAdmin, superAdmin])
console.log(validation.valid) // true
// Save to adapter
for (const role of [viewer, author, editor, orgAdmin, superAdmin]) {
await engine.admin.saveRole(role)
}