type safe config
Use createAccessConfig() to define your application's permission schema with compile-time type checking for actions, resources, scopes, roles, and conditions.
Overview
duck-iam provides two ways to define permissions:
-
Untyped builders --
defineRole(),policy(),defineRule(),when()imported directly from@gentleduck/iam. These accept any string for actions, resources, and scopes. Quick and flexible, but no compile-time validation. -
Typed config --
createAccessConfig()locks down your actions, resources, and scopes at the type level usingconstassertions. Every builder method is constrained to your declared schema. Misspell an action or reference a resource that does not exist, and TypeScript catches it before your code runs.
For production applications, the typed config is strongly recommended. It prevents an entire class of bugs where a permission check silently fails because of a typo in an action or resource name.
createAccessConfig()
The createAccessConfig() factory accepts your permission schema and returns an
AccessConfig object with typed builder methods.
import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
resources: ['post', 'comment', 'user', 'dashboard'] as const,
scopes: ['org-1', 'org-2'] as const,
})import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
resources: ['post', 'comment', 'user', 'dashboard'] as const,
scopes: ['org-1', 'org-2'] as const,
})The as const assertion is required. Without it, TypeScript widens the arrays to
string[] and you lose all type checking.
Input shape
interface AccessConfigInput<TActions, TResources, TScopes> {
actions: TActions // readonly string[] with const assertion
resources: TResources // readonly string[] with const assertion
scopes?: TScopes // optional, readonly string[] with const assertion
}interface AccessConfigInput<TActions, TResources, TScopes> {
actions: TActions // readonly string[] with const assertion
resources: TResources // readonly string[] with const assertion
scopes?: TScopes // optional, readonly string[] with const assertion
}If you omit scopes, the config still works -- scope parameters will accept string
without constraint.
AccessConfig Methods
The returned AccessConfig object exposes these typed methods:
access.defineRole()
Creates a typed role builder. Actions and resources are constrained to your schema.
const viewer = access.defineRole('viewer')
.grant('read', 'post') // ok
.grant('read', 'comment') // ok
// .grant('read', 'invoice') // ERROR: 'invoice' is not in resources
.build()
const editor = access.defineRole('editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
const admin = access.defineRole('admin')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.grant('manage', 'user')
.grant('manage', 'dashboard')
.build()const viewer = access.defineRole('viewer')
.grant('read', 'post') // ok
.grant('read', 'comment') // ok
// .grant('read', 'invoice') // ERROR: 'invoice' is not in resources
.build()
const editor = access.defineRole('editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
const admin = access.defineRole('admin')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.grant('manage', 'user')
.grant('manage', 'dashboard')
.build()access.policy()
Creates a typed policy builder. Rules within the policy are constrained to your schema.
The builder uses the same API as the standalone policy() function.
const ownerPolicy = access.policy('owner-only')
.name('Owner Only')
.algorithm('deny-overrides')
.rule('owner-update', r => r
.allow()
.on('update')
.of('post')
.priority(10)
.when(w => w.isOwner())
)
.rule('deny-non-owner-delete', r => r
.deny()
.on('delete')
.of('post')
.priority(20)
.when(w => w.check('resource.attributes.ownerId', 'neq', '$subject.id'))
)
.build()const ownerPolicy = access.policy('owner-only')
.name('Owner Only')
.algorithm('deny-overrides')
.rule('owner-update', r => r
.allow()
.on('update')
.of('post')
.priority(10)
.when(w => w.isOwner())
)
.rule('deny-non-owner-delete', r => r
.deny()
.on('delete')
.of('post')
.priority(20)
.when(w => w.check('resource.attributes.ownerId', 'neq', '$subject.id'))
)
.build()access.defineRule()
Creates a standalone typed rule builder, useful when composing rules across policies. Uses the same builder API as inline rules.
const ownerRule = access.defineRule('owner-check')
.allow()
.on('update', 'delete')
.of('post')
.priority(10)
.when(w => w.isOwner())
.build()
// Add to a policy with .addRule():
const p = access.policy('my-policy')
.name('My Policy')
.algorithm('deny-overrides')
.addRule(ownerRule)
.build()const ownerRule = access.defineRule('owner-check')
.allow()
.on('update', 'delete')
.of('post')
.priority(10)
.when(w => w.isOwner())
.build()
// Add to a policy with .addRule():
const p = access.policy('my-policy')
.name('My Policy')
.algorithm('deny-overrides')
.addRule(ownerRule)
.build()access.when()
Creates a typed condition builder for reusable condition groups. Use buildAll(),
buildAny(), or buildNone() to produce a ConditionGroup.
const isOwner = access.when()
.isOwner()
.buildAll()
// { all: [{ field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }] }
const isAdmin = access.when()
.contains('subject.roles', 'admin')
.buildAll()
// { all: [{ field: 'subject.roles', operator: 'contains', value: 'admin' }] }
const isAdminOrOwner = access.when()
.role('admin')
.isOwner()
.buildAny()
// { any: [...] } -- either condition is sufficientconst isOwner = access.when()
.isOwner()
.buildAll()
// { all: [{ field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }] }
const isAdmin = access.when()
.contains('subject.roles', 'admin')
.buildAll()
// { all: [{ field: 'subject.roles', operator: 'contains', value: 'admin' }] }
const isAdminOrOwner = access.when()
.role('admin')
.isOwner()
.buildAny()
// { any: [...] } -- either condition is sufficientaccess.createEngine()
Creates a typed engine instance. Permission checks on this engine are constrained to your schema.
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'user-1': ['editor'],
'user-2': ['viewer'],
},
policies: [ownerPolicy],
})
const engine = access.createEngine({ adapter })
// Typed permission checks:
await engine.can('user-1', 'read', { type: 'post', attributes: {} })
// ok
// await engine.can('user-1', 'approve', { type: 'post', attributes: {} })
// ERROR: 'approve' is not assignable to 'create' | 'read' | 'update' | 'delete' | 'manage'
// await engine.can('user-1', 'read', { type: 'invoice', attributes: {} })
// ERROR: 'invoice' is not assignable to 'post' | 'comment' | 'user' | 'dashboard'import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'user-1': ['editor'],
'user-2': ['viewer'],
},
policies: [ownerPolicy],
})
const engine = access.createEngine({ adapter })
// Typed permission checks:
await engine.can('user-1', 'read', { type: 'post', attributes: {} })
// ok
// await engine.can('user-1', 'approve', { type: 'post', attributes: {} })
// ERROR: 'approve' is not assignable to 'create' | 'read' | 'update' | 'delete' | 'manage'
// await engine.can('user-1', 'read', { type: 'invoice', attributes: {} })
// ERROR: 'invoice' is not assignable to 'post' | 'comment' | 'user' | 'dashboard'access.checks()
A pure typing utility -- it returns the input array as-is, but constrains the types at
compile time. Use this with engine.permissions() to batch-check multiple permissions
with full type safety.
const uiChecks = access.checks([
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
// { action: 'approve', resource: 'post' }
// ERROR: 'approve' is not assignable to type...
])
const perms = await engine.permissions('user-1', uiChecks)
// { 'create:post': true, 'update:post:post-1': true, ... }const uiChecks = access.checks([
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
// { action: 'approve', resource: 'post' }
// ERROR: 'approve' is not assignable to type...
])
const perms = await engine.permissions('user-1', uiChecks)
// { 'create:post': true, 'update:post:post-1': true, ... }access.validateRoles()
Runtime validation for role definitions. Same as the standalone validateRoles() but
available on the config object for convenience.
const result = access.validateRoles([viewer, editor, admin])
if (!result.valid) {
throw new Error(result.issues.map((i) => i.message).join(', '))
}const result = access.validateRoles([viewer, editor, admin])
if (!result.valid) {
throw new Error(result.issues.map((i) => i.message).join(', '))
}access.validatePolicy()
Runtime validation for untrusted policy objects. Same as the standalone validatePolicy().
const policyFromAPI = await fetch('/api/policies/123').then((r) => r.json())
const result = access.validatePolicy(policyFromAPI)
if (!result.valid) {
console.error('Invalid policy:', result.issues)
}const policyFromAPI = await fetch('/api/policies/123').then((r) => r.json())
const result = access.validatePolicy(policyFromAPI)
if (!result.valid) {
console.error('Invalid policy:', result.issues)
}How const Assertions Work
The key to type safety is TypeScript's as const assertion. Without it, arrays are widened:
// Without as const -- types are string[]
const actions = ['create', 'read', 'update']
// typeof actions = string[]
// With as const -- types are literal tuples
const actions = ['create', 'read', 'update'] as const
// typeof actions = readonly ['create', 'read', 'update']// Without as const -- types are string[]
const actions = ['create', 'read', 'update']
// typeof actions = string[]
// With as const -- types are literal tuples
const actions = ['create', 'read', 'update'] as const
// typeof actions = readonly ['create', 'read', 'update']When you pass as const arrays to createAccessConfig(), the factory uses conditional types
to extract the union of literal values:
type TAction = (typeof actions)[number]
// = 'create' | 'read' | 'update'type TAction = (typeof actions)[number]
// = 'create' | 'read' | 'update'This union type flows through all builders, constraining every parameter.
Full Example: Defining Your App's Permission Schema
Here is a complete example showing how to define and use a typed permission schema for a multi-tenant blog application:
import { createAccessConfig } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
// 1. Define the permission schema
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete', 'publish', 'manage'] as const,
resources: ['post', 'comment', 'user', 'analytics', 'settings'] as const,
scopes: ['org-acme', 'org-globex'] as const,
})
// 2. Define roles using typed builders
const viewer = access.defineRole('viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()
const author = access.defineRole('author')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.build()
const editor = access.defineRole('editor')
.inherits('author')
.grant('publish', 'post')
.grant('update', 'comment')
.grant('delete', 'comment')
.build()
const admin = access.defineRole('admin')
.inherits('editor')
.grant('delete', 'post')
.grant('manage', 'user')
.grant('manage', 'analytics')
.grant('manage', 'settings')
.build()
// 3. Validate roles at startup
const roleCheck = access.validateRoles([viewer, author, editor, admin])
if (!roleCheck.valid) {
throw new Error('Invalid roles: ' + roleCheck.issues.map((i) => i.message).join(', '))
}
// 4. Define policies for fine-grained rules
const ownerPolicy = access.policy('owner-restrictions')
.name('Owner Restrictions')
.algorithm('deny-overrides')
.rule('authors-own-posts-only', r => r
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
.not(w => w.role('admin'))
)
)
.build()
// 5. Create the engine
const adapter = new MemoryAdapter({
roles: [viewer, author, editor, admin],
assignments: {
'alice': ['admin'],
'bob': ['editor'],
'charlie': ['author'],
},
policies: [ownerPolicy],
})
const engine = access.createEngine({ adapter, cacheTTL: 120 })
// 6. Define typed permission checks for UI
const dashboardChecks = access.checks([
{ action: 'read', resource: 'analytics' },
{ action: 'manage', resource: 'analytics' },
{ action: 'manage', resource: 'settings' },
{ action: 'manage', resource: 'user' },
])
// 7. Use in your application
async function getDashboardPermissions(userId: string) {
return engine.permissions(userId, dashboardChecks)
}
// alice: { 'read:analytics': true, 'manage:analytics': true, ... }
// bob: { 'read:analytics': false, 'manage:analytics': false, ... }import { createAccessConfig } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
// 1. Define the permission schema
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete', 'publish', 'manage'] as const,
resources: ['post', 'comment', 'user', 'analytics', 'settings'] as const,
scopes: ['org-acme', 'org-globex'] as const,
})
// 2. Define roles using typed builders
const viewer = access.defineRole('viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()
const author = access.defineRole('author')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.build()
const editor = access.defineRole('editor')
.inherits('author')
.grant('publish', 'post')
.grant('update', 'comment')
.grant('delete', 'comment')
.build()
const admin = access.defineRole('admin')
.inherits('editor')
.grant('delete', 'post')
.grant('manage', 'user')
.grant('manage', 'analytics')
.grant('manage', 'settings')
.build()
// 3. Validate roles at startup
const roleCheck = access.validateRoles([viewer, author, editor, admin])
if (!roleCheck.valid) {
throw new Error('Invalid roles: ' + roleCheck.issues.map((i) => i.message).join(', '))
}
// 4. Define policies for fine-grained rules
const ownerPolicy = access.policy('owner-restrictions')
.name('Owner Restrictions')
.algorithm('deny-overrides')
.rule('authors-own-posts-only', r => r
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
.not(w => w.role('admin'))
)
)
.build()
// 5. Create the engine
const adapter = new MemoryAdapter({
roles: [viewer, author, editor, admin],
assignments: {
'alice': ['admin'],
'bob': ['editor'],
'charlie': ['author'],
},
policies: [ownerPolicy],
})
const engine = access.createEngine({ adapter, cacheTTL: 120 })
// 6. Define typed permission checks for UI
const dashboardChecks = access.checks([
{ action: 'read', resource: 'analytics' },
{ action: 'manage', resource: 'analytics' },
{ action: 'manage', resource: 'settings' },
{ action: 'manage', resource: 'user' },
])
// 7. Use in your application
async function getDashboardPermissions(userId: string) {
return engine.permissions(userId, dashboardChecks)
}
// alice: { 'read:analytics': true, 'manage:analytics': true, ... }
// bob: { 'read:analytics': false, 'manage:analytics': false, ... }Typed vs Untyped: Comparison
Untyped (direct imports)
import { defineRole, Engine, MemoryAdapter } from '@gentleduck/iam'
const viewer = defineRole('viewer')
.grant('raed', 'post') // typo: "raed" instead of "read" -- NO error
.build()
const engine = new Engine({ adapter })
await engine.can('user-1', 'raed', { type: 'post', attributes: {} })
// No TypeScript error, but silently fails at runtime because no role grants "raed"import { defineRole, Engine, MemoryAdapter } from '@gentleduck/iam'
const viewer = defineRole('viewer')
.grant('raed', 'post') // typo: "raed" instead of "read" -- NO error
.build()
const engine = new Engine({ adapter })
await engine.can('user-1', 'raed', { type: 'post', attributes: {} })
// No TypeScript error, but silently fails at runtime because no role grants "raed"Typed (createAccessConfig)
import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'comment'] as const,
})
const viewer = access.defineRole('viewer')
.grant('raed', 'post') // ERROR: '"raed"' is not assignable to '"create" | "read" | ...'
.build()import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'comment'] as const,
})
const viewer = access.defineRole('viewer')
.grant('raed', 'post') // ERROR: '"raed"' is not assignable to '"create" | "read" | ...'
.build()The typed version catches the typo immediately. For any application with more than a handful of permissions, this prevents real bugs.
When to Use Each Approach
| Scenario | Recommendation |
|---|---|
| Production application | Use createAccessConfig() -- type safety prevents bugs |
| Quick prototype or spike | Untyped imports are faster to set up |
| Dynamic permissions from DB | Use untyped for the dynamic parts, validate with validatePolicy() |
| Library or framework code | Use generic type parameters for maximum flexibility |
| Testing | Either works -- typed catches config mistakes, untyped is less verbose |