Skip to main content
Search...

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:

Loading diagram...

  • 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 using const assertions. 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 sufficient
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 sufficient

access.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

ScenarioRecommendation
Production applicationUse createAccessConfig() -- type safety prevents bugs
Quick prototype or spikeUntyped imports are faster to set up
Dynamic permissions from DBUse untyped for the dynamic parts, validate with validatePolicy()
Library or framework codeUse generic type parameters for maximum flexibility
TestingEither works -- typed catches config mistakes, untyped is less verbose