Skip to main content
Search...

chapter 8: production readiness

Ship BlogDuck to production with type-safe configuration, database adapters, validation, testing strategies, and security hardening.

Goal

BlogDuck is feature-complete. Before shipping, you need type safety across the stack, a real database adapter, startup validation, and a testing strategy. This chapter ties everything together into a production-grade authorization system.

Loading diagram...

Type-Safe Configuration

createAccessConfig is the foundation of a production setup. It locks down your actions, resources, and scopes so the entire stack is type-checked at compile time.

Define your configuration

src/access.ts
import { createAccessConfig } from '@gentleduck/iam'
 
export const access = createAccessConfig({
  actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
  resources: ['post', 'comment', 'user', 'dashboard'] as const,
  scopes: ['acme', 'globex'] as const,
})
src/access.ts
import { createAccessConfig } from '@gentleduck/iam'
 
export const access = createAccessConfig({
  actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
  resources: ['post', 'comment', 'user', 'dashboard'] as const,
  scopes: ['acme', 'globex'] as const,
})

The as const assertion is critical -- it tells TypeScript to preserve the literal string types. Without it, the types widen to string and you lose compile-time checking.

Use typed builders

src/access.ts
// All builders are now type-checked
export const viewer = access.defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
 
export const editor = access.defineRole('editor')
  .inherits('viewer')
  .grant('create', 'post')
  .grant('update', 'post')
  .grant('create', 'comment')
  .grant('update', 'comment')
  .build()
 
export const admin = access.defineRole('admin')
  .inherits('editor')
  .grant('delete', 'post')
  .grant('delete', 'comment')
  .grant('manage', 'user')
  .grant('manage', 'dashboard')
  .build()
 
// Compile error: 'execute' is not a valid action
// access.defineRole('bad').grant('execute', 'post')
 
// Compile error: 'order' is not a valid resource
// access.defineRole('bad').grant('read', 'order')
src/access.ts
// All builders are now type-checked
export const viewer = access.defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
 
export const editor = access.defineRole('editor')
  .inherits('viewer')
  .grant('create', 'post')
  .grant('update', 'post')
  .grant('create', 'comment')
  .grant('update', 'comment')
  .build()
 
export const admin = access.defineRole('admin')
  .inherits('editor')
  .grant('delete', 'post')
  .grant('delete', 'comment')
  .grant('manage', 'user')
  .grant('manage', 'dashboard')
  .build()
 
// Compile error: 'execute' is not a valid action
// access.defineRole('bad').grant('execute', 'post')
 
// Compile error: 'order' is not a valid resource
// access.defineRole('bad').grant('read', 'order')

Every .grant(), .on(), .of(), and .scope() call is type-checked against your config's actions, resources, and scopes.

Define typed policies

src/access.ts
export const ownerPolicy = access.policy('owner-restrictions')
  .name('Owner Restrictions')
  .algorithm('deny-overrides')
  .rule('deny-non-owner-update', r => r
    .deny()
    .on('update', 'delete')  // type-checked
    .of('post')              // type-checked
    .priority(100)
    .when(w => w
      .check('resource.attributes.ownerId', 'neq', '$subject.id')
      .not(n => n.role('admin'))
    )
  )
  .build()
src/access.ts
export const ownerPolicy = access.policy('owner-restrictions')
  .name('Owner Restrictions')
  .algorithm('deny-overrides')
  .rule('deny-non-owner-update', r => r
    .deny()
    .on('update', 'delete')  // type-checked
    .of('post')              // type-checked
    .priority(100)
    .when(w => w
      .check('resource.attributes.ownerId', 'neq', '$subject.id')
      .not(n => n.role('admin'))
    )
  )
  .build()

Define typed permission checks

src/access.ts
export const appChecks = access.checks([
  { action: 'create', resource: 'post' },
  { action: 'update', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'dashboard' },
  { action: 'manage', resource: 'user' },
] as const)
 
// Compile error: 'publish' is not a valid action
// access.checks([{ action: 'publish', resource: 'post' }])
src/access.ts
export const appChecks = access.checks([
  { action: 'create', resource: 'post' },
  { action: 'update', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'dashboard' },
  { action: 'manage', resource: 'user' },
] as const)
 
// Compile error: 'publish' is not a valid action
// access.checks([{ action: 'publish', resource: 'post' }])

checks() returns the same array -- it is a zero-cost type assertion at runtime. Use this array with engine.permissions() to generate client permission maps.

Create the engine

src/access.ts
export const engine = access.createEngine({
  adapter,
  cacheTTL: 60,
  maxCacheSize: 1000,
  hooks: {
    afterEvaluate: async (request, decision) => {
      console.log(`[audit] ${request.subject.id} ${decision.effect} ${request.action}:${request.resource.type}`)
    },
    onError: async (error) => {
      console.error('[auth-error]', error)
    },
  },
})
src/access.ts
export const engine = access.createEngine({
  adapter,
  cacheTTL: 60,
  maxCacheSize: 1000,
  hooks: {
    afterEvaluate: async (request, decision) => {
      console.log(`[audit] ${request.subject.id} ${decision.effect} ${request.action}:${request.resource.type}`)
    },
    onError: async (error) => {
      console.error('[auth-error]', error)
    },
  },
})

The AccessConfigInput Interface

interface AccessConfigInput<
  TActions extends readonly string[],
  TResources extends readonly string[],
  TScopes extends readonly string[] = Readonly<[]>,
> {
  readonly actions: TActions
  readonly resources: TResources
  readonly scopes?: TScopes     // optional -- defaults to empty
}
interface AccessConfigInput<
  TActions extends readonly string[],
  TResources extends readonly string[],
  TScopes extends readonly string[] = Readonly<[]>,
> {
  readonly actions: TActions
  readonly resources: TResources
  readonly scopes?: TScopes     // optional -- defaults to empty
}

Scopes are optional. If you don't need multi-tenancy, omit scopes entirely:

const access = createAccessConfig({
  actions: ['read', 'write', 'delete'] as const,
  resources: ['post', 'comment'] as const,
  // no scopes -- single-tenant app
})
const access = createAccessConfig({
  actions: ['read', 'write', 'delete'] as const,
  resources: ['post', 'comment'] as const,
  // no scopes -- single-tenant app
})

The AccessConfig Output Object

createAccessConfig() returns an object with typed factory methods:

MethodReturnsPurpose
defineRole(id)RoleBuilder<TAction, TResource, TId, TScope>Create a role with type-checked grants
policy(id)PolicyBuilder<TAction, TResource, string, TScope>Create a policy with typed rules
defineRule(id)RuleBuilder<TAction, TResource, TScope>Create a standalone rule
when()When<TAction, TResource, string, TScope>Create a standalone condition group
createEngine(config)Engine<TAction, TResource, TRole, TScope>Create a typed engine instance
checks(arr)T (pass-through)Type-validate permission check definitions
validateRoles(roles)ValidationResultValidate role definitions at startup
validatePolicy(input)ValidationResultValidate policy from untrusted source

Utility Types

duck-iam exports three utility types to extract the literal union types from your config:

import type { InferAction, InferResource, InferScope } from '@gentleduck/iam'
 
const config = {
  actions: ['create', 'read', 'update', 'delete'] as const,
  resources: ['post', 'comment', 'user'] as const,
  scopes: ['acme', 'globex'] as const,
}
 
type Action = InferAction<typeof config>
// 'create' | 'read' | 'update' | 'delete'
 
type Resource = InferResource<typeof config>
// 'post' | 'comment' | 'user'
 
type Scope = InferScope<typeof config>
// 'acme' | 'globex'
import type { InferAction, InferResource, InferScope } from '@gentleduck/iam'
 
const config = {
  actions: ['create', 'read', 'update', 'delete'] as const,
  resources: ['post', 'comment', 'user'] as const,
  scopes: ['acme', 'globex'] as const,
}
 
type Action = InferAction<typeof config>
// 'create' | 'read' | 'update' | 'delete'
 
type Resource = InferResource<typeof config>
// 'post' | 'comment' | 'user'
 
type Scope = InferScope<typeof config>
// 'acme' | 'globex'

Use these when you need to type function parameters, API route handlers, or custom adapters against your specific config.

// Type definitions
type InferAction<S extends { actions: readonly string[] }> = S['actions'][number]
type InferResource<S extends { resources: readonly string[] }> = S['resources'][number]
type InferScope<S extends { scopes: readonly string[] }> = S['scopes'][number]
// Type definitions
type InferAction<S extends { actions: readonly string[] }> = S['actions'][number]
type InferResource<S extends { resources: readonly string[] }> = S['resources'][number]
type InferScope<S extends { scopes: readonly string[] }> = S['scopes'][number]

The Adapter Interface

Every adapter implements the Adapter interface, which is the union of three stores:

interface Adapter<TAction, TResource, TRole, TScope>
  extends PolicyStore<TAction, TResource, TRole>,
    RoleStore<TAction, TResource, TRole, TScope>,
    SubjectStore<TRole, TScope> {}
interface Adapter<TAction, TResource, TRole, TScope>
  extends PolicyStore<TAction, TResource, TRole>,
    RoleStore<TAction, TResource, TRole, TScope>,
    SubjectStore<TRole, TScope> {}

PolicyStore

Manages policy CRUD:

interface PolicyStore<TAction, TResource, TRole> {
  listPolicies(): Promise<Policy<TAction, TResource, TRole>[]>
  getPolicy(id: string): Promise<Policy<TAction, TResource, TRole> | null>
  savePolicy(policy: Policy<TAction, TResource, TRole>): Promise<void>
  deletePolicy(id: string): Promise<void>
}
interface PolicyStore<TAction, TResource, TRole> {
  listPolicies(): Promise<Policy<TAction, TResource, TRole>[]>
  getPolicy(id: string): Promise<Policy<TAction, TResource, TRole> | null>
  savePolicy(policy: Policy<TAction, TResource, TRole>): Promise<void>
  deletePolicy(id: string): Promise<void>
}
MethodCalled ByDescription
listPolicies()Engine (cached)Load all ABAC policies for evaluation
getPolicy(id)Admin APIFetch a single policy by ID
savePolicy(policy)Admin APICreate or update a policy (upsert)
deletePolicy(id)Admin APIRemove a policy

RoleStore

Manages role CRUD:

interface RoleStore<TAction, TResource, TRole, TScope> {
  listRoles(): Promise<Role<TAction, TResource, TRole, TScope>[]>
  getRole(id: string): Promise<Role<TAction, TResource, TRole, TScope> | null>
  saveRole(role: Role<TAction, TResource, TRole, TScope>): Promise<void>
  deleteRole(id: string): Promise<void>
}
interface RoleStore<TAction, TResource, TRole, TScope> {
  listRoles(): Promise<Role<TAction, TResource, TRole, TScope>[]>
  getRole(id: string): Promise<Role<TAction, TResource, TRole, TScope> | null>
  saveRole(role: Role<TAction, TResource, TRole, TScope>): Promise<void>
  deleteRole(id: string): Promise<void>
}
MethodCalled ByDescription
listRoles()Engine (cached)Load all roles for RBAC policy generation
getRole(id)Admin APIFetch a single role by ID
saveRole(role)Admin APICreate or update a role (upsert)
deleteRole(id)Admin APIRemove a role

SubjectStore

Manages user data:

interface SubjectStore<TRole, TScope> {
  getSubjectRoles(subjectId: string): Promise<TRole[]>
  getSubjectScopedRoles?(subjectId: string): Promise<ScopedRole<TRole, TScope>[]>
  assignRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  revokeRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  getSubjectAttributes(subjectId: string): Promise<Attributes>
  setSubjectAttributes(subjectId: string, attrs: Attributes): Promise<void>
}
interface SubjectStore<TRole, TScope> {
  getSubjectRoles(subjectId: string): Promise<TRole[]>
  getSubjectScopedRoles?(subjectId: string): Promise<ScopedRole<TRole, TScope>[]>
  assignRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  revokeRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  getSubjectAttributes(subjectId: string): Promise<Attributes>
  setSubjectAttributes(subjectId: string, attrs: Attributes): Promise<void>
}
MethodCalled ByDescription
getSubjectRoles(id)resolveSubject()Get base (unscoped) role assignments
getSubjectScopedRoles?(id)resolveSubject()Get scoped role assignments (optional)
assignRole(id, role, scope?)Admin APIAssign a role (optionally scoped)
revokeRole(id, role, scope?)Admin APIRemove a role assignment
getSubjectAttributes(id)resolveSubject()Get user attributes for ABAC conditions
setSubjectAttributes(id, attrs)Admin APIMerge attributes into user record

getSubjectScopedRoles is optional (note the ?). If your adapter does not implement it, the engine skips scoped role enrichment. Implement it when you need multi-tenant scoping (Chapter 5).

How resolveSubject() Uses the Adapter

When the engine resolves a subject, it makes three parallel calls to the adapter:

Loading diagram...

const [assignedRoles, attributes, allRoles] = await Promise.all([
  this.adapter.getSubjectRoles(subjectId),
  this.adapter.getSubjectAttributes(subjectId),
  this.loadRoles(),   // cached separately
])
 
const roles = resolveEffectiveRoles(assignedRoles, allRoles)
 
const scopedRoles = this.adapter.getSubjectScopedRoles
  ? await this.adapter.getSubjectScopedRoles(subjectId)
  : undefined
 
const subject: Subject = { id: subjectId, roles, scopedRoles, attributes }
const [assignedRoles, attributes, allRoles] = await Promise.all([
  this.adapter.getSubjectRoles(subjectId),
  this.adapter.getSubjectAttributes(subjectId),
  this.loadRoles(),   // cached separately
])
 
const roles = resolveEffectiveRoles(assignedRoles, allRoles)
 
const scopedRoles = this.adapter.getSubjectScopedRoles
  ? await this.adapter.getSubjectScopedRoles(subjectId)
  : undefined
 
const subject: Subject = { id: subjectId, roles, scopedRoles, attributes }

resolveEffectiveRoles() expands the inheritance chain. If Alice has role editor, and editor inherits viewer, the effective roles are ['editor', 'viewer']. Cycles are handled via a visited set -- if role A inherits B and B inherits A, the walk stops at the cycle.

Database Adapters

In production, you need persistent storage. duck-iam provides four adapters.

MemoryAdapter

For prototyping, testing, and simple apps:

import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
const adapter = new MemoryAdapter({
  policies: [ownerPolicy],
  roles: [viewer, editor, admin],
  assignments: {
    'alice': ['viewer'],
    'bob': ['editor'],
    'charlie': ['admin'],
  },
  attributes: {
    'alice': { department: 'engineering' },
  },
})
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
const adapter = new MemoryAdapter({
  policies: [ownerPolicy],
  roles: [viewer, editor, admin],
  assignments: {
    'alice': ['viewer'],
    'bob': ['editor'],
    'charlie': ['admin'],
  },
  attributes: {
    'alice': { department: 'engineering' },
  },
})

MemoryAdapterInit Interface

interface MemoryAdapterInit<TAction, TResource, TRole, TScope> {
  policies?: Policy<TAction, TResource, TRole>[]
  roles?: Role<TAction, TResource, TRole, TScope>[]
  assignments?: Record<string, TRole[]>         // subjectId -> base roles
  attributes?: Record<string, Attributes>        // subjectId -> attributes
}
interface MemoryAdapterInit<TAction, TResource, TRole, TScope> {
  policies?: Policy<TAction, TResource, TRole>[]
  roles?: Role<TAction, TResource, TRole, TScope>[]
  assignments?: Record<string, TRole[]>         // subjectId -> base roles
  attributes?: Record<string, Attributes>        // subjectId -> attributes
}

The MemoryAdapter stores everything in Map objects. Internally it tracks assignments as { role, scope? } pairs. The assignments init option only sets unscoped (base) roles. For scoped assignments, use adapter.assignRole(subjectId, roleId, scope) after creation.

How it separates base and scoped roles:

  • getSubjectRoles() returns only entries where scope == null (base roles)
  • getSubjectScopedRoles() returns only entries where scope != null
  • assignRole() prevents duplicates before inserting
  • setSubjectAttributes() merges into existing attributes (does not replace)

PrismaAdapter

src/access.ts
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { PrismaClient } from '@prisma/client'
 
const prisma = new PrismaClient()
const adapter = new PrismaAdapter(prisma)
src/access.ts
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { PrismaClient } from '@prisma/client'
 
const prisma = new PrismaClient()
const adapter = new PrismaAdapter(prisma)

Loading diagram...

Required Prisma models:

prisma/schema.prisma
model accessPolicy {
  id          String  @id
  name        String
  description String?
  version     Int     @default(1)
  algorithm   String
  rules       Json
  targets     Json?
}
 
model accessRole {
  id          String  @id
  name        String
  description String?
  permissions Json
  inherits    Json    @default("[]")
  scope       String?
  metadata    Json?
}
 
model accessAssignment {
  subjectId String
  roleId    String
  scope     String?
 
  @@id([subjectId, roleId, scope])
}
 
model accessSubjectAttr {
  subjectId String @id
  data      Json
}
prisma/schema.prisma
model accessPolicy {
  id          String  @id
  name        String
  description String?
  version     Int     @default(1)
  algorithm   String
  rules       Json
  targets     Json?
}
 
model accessRole {
  id          String  @id
  name        String
  description String?
  permissions Json
  inherits    Json    @default("[]")
  scope       String?
  metadata    Json?
}
 
model accessAssignment {
  subjectId String
  roleId    String
  scope     String?
 
  @@id([subjectId, roleId, scope])
}
 
model accessSubjectAttr {
  subjectId String @id
  data      Json
}

Prisma Column Mapping

The adapter maps between duck-iam types and database columns:

duck-iam FieldPrisma ColumnTypeNotes
policy.idaccessPolicy.idString @idPrimary key
policy.nameaccessPolicy.nameStringDisplay name
policy.descriptionaccessPolicy.descriptionString?null in DB, undefined in duck-iam
policy.versionaccessPolicy.versionIntDefaults to 1
policy.algorithmaccessPolicy.algorithmStringOne of 4 combining algorithms
policy.rulesaccessPolicy.rulesJsonStored as JSON, parsed on read
policy.targetsaccessPolicy.targetsJson?Optional targeting object
role.permissionsaccessRole.permissionsJsonArray of Permission objects
role.inheritsaccessRole.inheritsJsonArray of role IDs, defaults to []
role.scopeaccessRole.scopeString?Role-level scope constraint
role.metadataaccessRole.metadataJson?Custom metadata
assignment.scopeaccessAssignment.scopeString?null = base role, non-null = scoped
attrs.dataaccessSubjectAttr.dataJsonMerged on write, full object on read

The PrismaAdapter uses upsert for save operations (create or update). For revokeRole, it uses deleteMany with a compound filter on (subjectId, roleId, scope).

DrizzleAdapter

src/access.ts
import { DrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'
import { db } from './db'
import { eq, and } from 'drizzle-orm'
import * as tables from './schema'
 
const adapter = new DrizzleAdapter({
  db,
  tables: {
    policies: tables.accessPolicies,
    roles: tables.accessRoles,
    assignments: tables.accessAssignments,
    attrs: tables.accessSubjectAttrs,
  },
  ops: { eq, and },
})
src/access.ts
import { DrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'
import { db } from './db'
import { eq, and } from 'drizzle-orm'
import * as tables from './schema'
 
const adapter = new DrizzleAdapter({
  db,
  tables: {
    policies: tables.accessPolicies,
    roles: tables.accessRoles,
    assignments: tables.accessAssignments,
    attrs: tables.accessSubjectAttrs,
  },
  ops: { eq, and },
})

DrizzleConfig Interface

interface DrizzleConfig {
  db: {
    select: () => { from: (table: unknown) => DrizzleQuery }
    insert: (table: unknown) => { values: (data: Record<string, unknown>) => DrizzleInsert }
    delete: (table: unknown) => { where: (condition: unknown) => Promise<unknown> }
  }
  tables: {
    policies: DrizzleTable
    roles: DrizzleTable
    assignments: DrizzleTable
    attrs: DrizzleTable
  }
  ops: {
    eq: (col: unknown, val: unknown) => unknown
    and: (...conditions: unknown[]) => unknown
  }
}
interface DrizzleConfig {
  db: {
    select: () => { from: (table: unknown) => DrizzleQuery }
    insert: (table: unknown) => { values: (data: Record<string, unknown>) => DrizzleInsert }
    delete: (table: unknown) => { where: (condition: unknown) => Promise<unknown> }
  }
  tables: {
    policies: DrizzleTable
    roles: DrizzleTable
    assignments: DrizzleTable
    attrs: DrizzleTable
  }
  ops: {
    eq: (col: unknown, val: unknown) => unknown
    and: (...conditions: unknown[]) => unknown
  }
}

Key differences from PrismaAdapter:

  • JSON columns may store as string -- the adapter handles both string (parses with JSON.parse) and object (uses directly) values
  • Uses onConflictDoUpdate for upsert and onConflictDoNothing for assignRole
  • Requires you to pass the eq and and operators from drizzle-orm
  • Serializes JSON fields with JSON.stringify before writing

Drizzle Schema Example

src/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
 
export const accessPolicies = sqliteTable('access_policies', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  version: integer('version').notNull().default(1),
  algorithm: text('algorithm').notNull(),
  rules: text('rules').notNull(),        // JSON string
  targets: text('targets'),              // JSON string or null
})
 
export const accessRoles = sqliteTable('access_roles', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  permissions: text('permissions').notNull(),  // JSON string
  inherits: text('inherits').notNull().default('[]'),
  scope: text('scope'),
  metadata: text('metadata'),
})
 
export const accessAssignments = sqliteTable('access_assignments', {
  subjectId: text('subject_id').notNull(),
  roleId: text('role_id').notNull(),
  scope: text('scope'),
}, (t) => ({
  pk: primaryKey(t.subjectId, t.roleId, t.scope),
}))
 
export const accessSubjectAttrs = sqliteTable('access_subject_attrs', {
  subjectId: text('subject_id').primaryKey(),
  data: text('data').notNull(),   // JSON string
})
src/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
 
export const accessPolicies = sqliteTable('access_policies', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  version: integer('version').notNull().default(1),
  algorithm: text('algorithm').notNull(),
  rules: text('rules').notNull(),        // JSON string
  targets: text('targets'),              // JSON string or null
})
 
export const accessRoles = sqliteTable('access_roles', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  permissions: text('permissions').notNull(),  // JSON string
  inherits: text('inherits').notNull().default('[]'),
  scope: text('scope'),
  metadata: text('metadata'),
})
 
export const accessAssignments = sqliteTable('access_assignments', {
  subjectId: text('subject_id').notNull(),
  roleId: text('role_id').notNull(),
  scope: text('scope'),
}, (t) => ({
  pk: primaryKey(t.subjectId, t.roleId, t.scope),
}))
 
export const accessSubjectAttrs = sqliteTable('access_subject_attrs', {
  subjectId: text('subject_id').primaryKey(),
  data: text('data').notNull(),   // JSON string
})

HttpAdapter

For microservice architectures where a central service manages authorization:

src/access.ts
import { HttpAdapter } from '@gentleduck/iam/adapters/http'
 
const adapter = new HttpAdapter({
  baseUrl: 'https://auth.internal.company.com/access',
  headers: () => ({
    Authorization: `Bearer ${getServiceToken()}`,
  }),
})
src/access.ts
import { HttpAdapter } from '@gentleduck/iam/adapters/http'
 
const adapter = new HttpAdapter({
  baseUrl: 'https://auth.internal.company.com/access',
  headers: () => ({
    Authorization: `Bearer ${getServiceToken()}`,
  }),
})

HttpAdapterConfig Interface

interface HttpAdapterConfig {
  /** Base URL of your duck-iam API, e.g. "https://api.example.com/access" */
  baseUrl: string
  /** Custom fetch function (defaults to globalThis.fetch) */
  fetch?: typeof globalThis.fetch
  /** Headers to include -- static object or async function */
  headers?: Record<string, string>
    | (() => Record<string, string> | Promise<Record<string, string>>)
}
interface HttpAdapterConfig {
  /** Base URL of your duck-iam API, e.g. "https://api.example.com/access" */
  baseUrl: string
  /** Custom fetch function (defaults to globalThis.fetch) */
  fetch?: typeof globalThis.fetch
  /** Headers to include -- static object or async function */
  headers?: Record<string, string>
    | (() => Record<string, string> | Promise<Record<string, string>>)
}

Features:

  • headers can be a static object or an async function (for rotating tokens)
  • fetch can be customized (for testing or Node.js polyfill)
  • Trailing slashes on baseUrl are automatically stripped
  • All requests include Content-Type: application/json
  • Non-OK responses throw Error with duck-iam HTTP ${status}: ${body}

HttpAdapter Endpoint Mapping

The adapter maps each store method to an HTTP endpoint:

MethodHTTP RequestNotes
listPolicies()GET /policiesReturns Policy[]
getPolicy(id)GET /policies/:idReturns Policy | null
savePolicy(p)PUT /policiesBody: full Policy JSON
deletePolicy(id)DELETE /policies/:id
listRoles()GET /rolesReturns Role[]
getRole(id)GET /roles/:idReturns Role | null
saveRole(r)PUT /rolesBody: full Role JSON
deleteRole(id)DELETE /roles/:id
getSubjectRoles(id)GET /subjects/:id/rolesReturns string[]
getSubjectScopedRoles(id)GET /subjects/:id/scoped-rolesReturns ScopedRole[]
assignRole(id, role, scope?)POST /subjects/:id/rolesBody: { roleId, scope }
revokeRole(id, role, scope?)DELETE /subjects/:id/roles/:role?scope=...Query param for scope
getSubjectAttributes(id)GET /subjects/:id/attributesReturns Attributes
setSubjectAttributes(id, attrs)PATCH /subjects/:id/attributesBody: partial attrs (merged)

These endpoints match the admin router from Chapter 6. Run one central duck-iam service with Express + admin router, and have other services connect via the HTTP adapter.

Writing a Custom Adapter

Implement the Adapter interface to connect any storage backend:

import type { Adapter, Attributes, Policy, Role, ScopedRole } from '@gentleduck/iam'
 
export class MongoAdapter implements Adapter {
  constructor(private db: Db) {}
 
  // PolicyStore
  async listPolicies(): Promise<Policy[]> {
    return this.db.collection('policies').find().toArray()
  }
  async getPolicy(id: string): Promise<Policy | null> {
    return this.db.collection('policies').findOne({ id })
  }
  async savePolicy(p: Policy): Promise<void> {
    await this.db.collection('policies').updateOne(
      { id: p.id }, { $set: p }, { upsert: true }
    )
  }
  async deletePolicy(id: string): Promise<void> {
    await this.db.collection('policies').deleteOne({ id })
  }
 
  // RoleStore
  async listRoles(): Promise<Role[]> {
    return this.db.collection('roles').find().toArray()
  }
  async getRole(id: string): Promise<Role | null> {
    return this.db.collection('roles').findOne({ id })
  }
  async saveRole(r: Role): Promise<void> {
    await this.db.collection('roles').updateOne(
      { id: r.id }, { $set: r }, { upsert: true }
    )
  }
  async deleteRole(id: string): Promise<void> {
    await this.db.collection('roles').deleteOne({ id })
  }
 
  // SubjectStore
  async getSubjectRoles(subjectId: string): Promise<string[]> {
    const rows = await this.db.collection('assignments')
      .find({ subjectId, scope: null }).toArray()
    return rows.map(r => r.roleId)
  }
  async getSubjectScopedRoles(subjectId: string): Promise<ScopedRole[]> {
    const rows = await this.db.collection('assignments')
      .find({ subjectId, scope: { $ne: null } }).toArray()
    return rows.map(r => ({ role: r.roleId, scope: r.scope }))
  }
  async assignRole(subjectId: string, roleId: string, scope?: string): Promise<void> {
    await this.db.collection('assignments').updateOne(
      { subjectId, roleId, scope: scope ?? null },
      { $set: { subjectId, roleId, scope: scope ?? null } },
      { upsert: true }
    )
  }
  async revokeRole(subjectId: string, roleId: string, scope?: string): Promise<void> {
    await this.db.collection('assignments').deleteOne({
      subjectId, roleId, scope: scope ?? null,
    })
  }
  async getSubjectAttributes(subjectId: string): Promise<Attributes> {
    const doc = await this.db.collection('subject_attrs').findOne({ subjectId })
    return doc?.data ?? {}
  }
  async setSubjectAttributes(subjectId: string, attrs: Attributes): Promise<void> {
    const existing = await this.getSubjectAttributes(subjectId)
    const merged = { ...existing, ...attrs }
    await this.db.collection('subject_attrs').updateOne(
      { subjectId },
      { $set: { subjectId, data: merged } },
      { upsert: true }
    )
  }
}
import type { Adapter, Attributes, Policy, Role, ScopedRole } from '@gentleduck/iam'
 
export class MongoAdapter implements Adapter {
  constructor(private db: Db) {}
 
  // PolicyStore
  async listPolicies(): Promise<Policy[]> {
    return this.db.collection('policies').find().toArray()
  }
  async getPolicy(id: string): Promise<Policy | null> {
    return this.db.collection('policies').findOne({ id })
  }
  async savePolicy(p: Policy): Promise<void> {
    await this.db.collection('policies').updateOne(
      { id: p.id }, { $set: p }, { upsert: true }
    )
  }
  async deletePolicy(id: string): Promise<void> {
    await this.db.collection('policies').deleteOne({ id })
  }
 
  // RoleStore
  async listRoles(): Promise<Role[]> {
    return this.db.collection('roles').find().toArray()
  }
  async getRole(id: string): Promise<Role | null> {
    return this.db.collection('roles').findOne({ id })
  }
  async saveRole(r: Role): Promise<void> {
    await this.db.collection('roles').updateOne(
      { id: r.id }, { $set: r }, { upsert: true }
    )
  }
  async deleteRole(id: string): Promise<void> {
    await this.db.collection('roles').deleteOne({ id })
  }
 
  // SubjectStore
  async getSubjectRoles(subjectId: string): Promise<string[]> {
    const rows = await this.db.collection('assignments')
      .find({ subjectId, scope: null }).toArray()
    return rows.map(r => r.roleId)
  }
  async getSubjectScopedRoles(subjectId: string): Promise<ScopedRole[]> {
    const rows = await this.db.collection('assignments')
      .find({ subjectId, scope: { $ne: null } }).toArray()
    return rows.map(r => ({ role: r.roleId, scope: r.scope }))
  }
  async assignRole(subjectId: string, roleId: string, scope?: string): Promise<void> {
    await this.db.collection('assignments').updateOne(
      { subjectId, roleId, scope: scope ?? null },
      { $set: { subjectId, roleId, scope: scope ?? null } },
      { upsert: true }
    )
  }
  async revokeRole(subjectId: string, roleId: string, scope?: string): Promise<void> {
    await this.db.collection('assignments').deleteOne({
      subjectId, roleId, scope: scope ?? null,
    })
  }
  async getSubjectAttributes(subjectId: string): Promise<Attributes> {
    const doc = await this.db.collection('subject_attrs').findOne({ subjectId })
    return doc?.data ?? {}
  }
  async setSubjectAttributes(subjectId: string, attrs: Attributes): Promise<void> {
    const existing = await this.getSubjectAttributes(subjectId)
    const merged = { ...existing, ...attrs }
    await this.db.collection('subject_attrs').updateOne(
      { subjectId },
      { $set: { subjectId, data: merged } },
      { upsert: true }
    )
  }
}

Requirements for a correct adapter:

  1. getSubjectRoles() must return only unscoped (base) roles
  2. getSubjectScopedRoles() must return only scoped roles (with scope set)
  3. assignRole() should prevent duplicate assignments
  4. setSubjectAttributes() should merge into existing attributes, not replace them
  5. savePolicy() and saveRole() should upsert (create if new, update if existing)
  6. getPolicy() and getRole() must return null when the ID does not exist

Cache Internals

The engine uses four LRU caches to minimize adapter calls:

Loading diagram...

CacheMax SizeKeyStoresInvalidated By
policyCache1'all'All ABAC policiesinvalidatePolicies(), invalidate()
roleCache1'all'All role definitionsinvalidateRoles(), invalidate()
rbacPolicyCache1'rbac'Generated __rbac__ policyinvalidateRoles(), invalidate()
subjectCachemaxCacheSizeSubject IDResolved SubjectinvalidateSubject(id), invalidateRoles(), invalidate()

LRU Cache Implementation

The cache is a custom LRU (Least Recently Used) implementation backed by a Map:

class LRUCache<V> {
  private map = new Map<string, { value: V; expiresAt: number }>()
  private maxSize: number
  private ttl: number
 
  constructor(maxSize: number, ttlMs: number) { ... }
  get(key: string): V | undefined { ... }
  set(key: string, value: V): void { ... }
  delete(key: string): boolean { ... }
  clear(): void { ... }
  get size(): number { ... }
}
class LRUCache<V> {
  private map = new Map<string, { value: V; expiresAt: number }>()
  private maxSize: number
  private ttl: number
 
  constructor(maxSize: number, ttlMs: number) { ... }
  get(key: string): V | undefined { ... }
  set(key: string, value: V): void { ... }
  delete(key: string): boolean { ... }
  clear(): void { ... }
  get size(): number { ... }
}

Eviction strategy:

  1. TTL expiry: When get() finds an entry past expiresAt, it deletes and returns undefined. Stale entries are lazily cleaned on access.
  2. LRU eviction: When set() would exceed maxSize, the oldest entry (first in Map iteration order) is evicted. On get(), the accessed entry is moved to the end (most recently used) by delete + re-insert.

TTL calculation:

const ttl = (config.cacheTTL ?? 60) * 1000  // config is in seconds, stored in ms
const ttl = (config.cacheTTL ?? 60) * 1000  // config is in seconds, stored in ms

Set cacheTTL: 0 in tests to disable caching (entries expire immediately).

Cache Invalidation Rules

The admin API auto-invalidates the correct caches after each mutation:

Admin MethodCache InvalidatedWhy
savePolicy()policyCachePolicy list changed
deletePolicy()policyCachePolicy list changed
saveRole()roleCache, rbacPolicyCache, subjectCacheRole definition changed, affects RBAC policy and all resolved subjects
deleteRole()roleCache, rbacPolicyCache, subjectCacheSame as above
assignRole()subjectCache[subjectId]Only this user's roles changed
revokeRole()subjectCache[subjectId]Only this user's roles changed
setAttributes()subjectCache[subjectId]Only this user's attributes changed
getAttributes()(none)Read-only operation

Role mutations (saveRole, deleteRole) clear ALL subject caches because resolved subjects contain expanded role information. If a role's inheritance chain changes, every cached subject who has that role is now stale.

Manual Cache Control

// Clear everything (nuclear option)
engine.invalidate()
 
// Clear only one user's cache (after external change)
engine.invalidateSubject('alice')
 
// Clear policy cache (after external DB update)
engine.invalidatePolicies()
 
// Clear role caches + all subjects (after external role change)
engine.invalidateRoles()
// Clear everything (nuclear option)
engine.invalidate()
 
// Clear only one user's cache (after external change)
engine.invalidateSubject('alice')
 
// Clear policy cache (after external DB update)
engine.invalidatePolicies()
 
// Clear role caches + all subjects (after external role change)
engine.invalidateRoles()

Startup Validation

Always validate your configuration at application startup:

src/access.ts
// Validate roles
const roleCheck = access.validateRoles([viewer, editor, admin])
if (!roleCheck.valid) {
  throw new Error(
    'Role config error: ' +
    roleCheck.issues.map(i => `[${i.type}] ${i.message}`).join(', ')
  )
}
 
// Validate policies
const policyCheck = access.validatePolicy(ownerPolicy)
if (!policyCheck.valid) {
  throw new Error(
    'Policy config error: ' +
    policyCheck.issues.map(i => `[${i.type}] ${i.message}`).join(', ')
  )
}
src/access.ts
// Validate roles
const roleCheck = access.validateRoles([viewer, editor, admin])
if (!roleCheck.valid) {
  throw new Error(
    'Role config error: ' +
    roleCheck.issues.map(i => `[${i.type}] ${i.message}`).join(', ')
  )
}
 
// Validate policies
const policyCheck = access.validatePolicy(ownerPolicy)
if (!policyCheck.valid) {
  throw new Error(
    'Policy config error: ' +
    policyCheck.issues.map(i => `[${i.type}] ${i.message}`).join(', ')
  )
}

ValidationResult and ValidationIssue

interface ValidationResult {
  readonly valid: boolean                   // true if no errors (warnings are OK)
  readonly issues: readonly ValidationIssue[]
}
 
interface ValidationIssue {
  readonly type: 'error' | 'warning'
  readonly code: string                    // machine-readable code
  readonly message: string                 // human-readable description
  readonly roleId?: string                 // which role (for role validation)
  readonly path?: string                   // JSON path (for policy validation)
}
interface ValidationResult {
  readonly valid: boolean                   // true if no errors (warnings are OK)
  readonly issues: readonly ValidationIssue[]
}
 
interface ValidationIssue {
  readonly type: 'error' | 'warning'
  readonly code: string                    // machine-readable code
  readonly message: string                 // human-readable description
  readonly roleId?: string                 // which role (for role validation)
  readonly path?: string                   // JSON path (for policy validation)
}

valid is true when there are no error-level issues. Warnings do not block validity.

Role Validation Checks

validateRoles() detects common configuration mistakes:

CheckSeverityCodeExample
Duplicate role IDserrorDUPLICATE_ROLE_IDTwo roles both called 'editor'
Dangling inheritserrorDANGLING_INHERITeditor inherits 'reviewer' which does not exist
Circular inheritancewarningCIRCULAR_INHERITa inherits b, b inherits a
Empty roleswarningEMPTY_ROLERole with no permissions and no inheritance

Circular inheritance is a warning (not error) because the engine handles it at runtime with a visited set. However, it likely indicates a configuration mistake.

Policy Validation Checks

validatePolicy() deeply validates untrusted policy objects (from database, API, admin dashboard). Use it before feeding dynamic policies to the engine:

CheckSeverityCodeWhat It Validates
Not an objecterrorINVALID_TYPEPolicy must be a non-null, non-array object
Missing iderrorMISSING_FIELDid must be a non-empty string
Missing nameerrorMISSING_FIELDname must be a non-empty string
Invalid algorithmerrorINVALID_ALGORITHMMust be one of: deny-overrides, allow-overrides, first-match, highest-priority
Invalid versionerrorINVALID_TYPEversion must be a number if provided
Missing ruleserrorMISSING_FIELDrules must be an array
Invalid rule shapeerrorINVALID_RULEEach rule must be an object
Missing rule iderrorMISSING_FIELDRule id must be a non-empty string
Invalid effecterrorINVALID_EFFECTMust be 'allow' or 'deny'
Invalid priorityerrorINVALID_TYPEpriority must be a number
Missing actionserrorMISSING_FIELDactions must be a non-empty array
Missing resourceserrorMISSING_FIELDresources must be a non-empty array
Invalid operatorerrorINVALID_OPERATORMust be one of the 17 valid operators
Invalid conditionerrorINVALID_CONDITIONMust have field+operator or all/any/none
Invalid targetserrorINVALID_TYPEtargets.actions, targets.resources, targets.roles must be arrays
Duplicate rule IDswarningDUPLICATE_RULE_IDTwo rules with same ID in one policy

Valid Operators

The 17 operators accepted by policy validation:

eq, neq, gt, gte, lt, lte, in, nin, contains, not_contains, starts_with, ends_with, matches, exists, not_exists, subset_of, superset_of

Security Hardening

duck-iam includes several built-in protections:

Fail Closed

If any error occurs during evaluation, the engine returns deny:

// If beforeEvaluate throws, or the adapter fails, result is deny
const result = await engine.can('user', 'read', { type: 'post', attributes: {} })
// result === false (on error)
// If beforeEvaluate throws, or the adapter fails, result is deny
const result = await engine.can('user', 'read', { type: 'post', attributes: {} })
// result === false (on error)

The authorize() method catches all errors and returns a deny Decision:

// Inside engine.authorize():
catch (error) {
  if (this.hooks.onError) {
    await this.hooks.onError(error as Error, req)
  }
  return {
    allowed: false,
    effect: 'deny',
    reason: `Evaluation error: ${error.message}`,
    duration: 0,
    timestamp: Date.now(),
  }
}
// Inside engine.authorize():
catch (error) {
  if (this.hooks.onError) {
    await this.hooks.onError(error as Error, req)
  }
  return {
    allowed: false,
    effect: 'deny',
    reason: `Evaluation error: ${error.message}`,
    duration: 0,
    timestamp: Date.now(),
  }
}

The same fail-closed pattern applies to permissions() -- each check that throws returns false for that key.

Prototype Pollution Prevention

The field resolver (resolve()) blocks access to dangerous properties:

const ALLOWED_ROOTS = new Set(['subject', 'resource', 'environment'])
const BLOCKED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
const ALLOWED_ROOTS = new Set(['subject', 'resource', 'environment'])
const BLOCKED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])

Any path that starts with an unrecognized root returns null. Any path segment matching a blocked name returns null:

// These paths are blocked and return null:
// __proto__.constructor     -- blocked root
// subject.__proto__.pollute -- blocked segment
// constructor.prototype     -- blocked root AND segment
// resource.attributes.constructor -- blocked segment
// These paths are blocked and return null:
// __proto__.constructor     -- blocked root
// subject.__proto__.pollute -- blocked segment
// constructor.prototype     -- blocked root AND segment
// resource.attributes.constructor -- blocked segment

Additionally, only three top-level roots are allowed: subject, resource, and environment. Paths like process.env.SECRET or global.something return null.

The special shortcuts action and scope are handled before the path split:

if (path === 'action') return request.action
if (path === 'scope') return request.scope ?? null
if (path === 'action') return request.action
if (path === 'scope') return request.scope ?? null

Regex DoS Prevention

Condition values using the matches operator are limited:

  • Maximum regex length: 512 characters. Patterns longer than this return false.
  • Regex compilation cache: Up to 256 compiled patterns are cached in a Map. When the cache is full, the oldest entry is evicted (FIFO).
  • Invalid patterns: If new RegExp(pattern) throws, the cache stores nothing and matches returns false (fail closed).
const MAX_REGEX_LENGTH = 512
const REGEX_CACHE_MAX = 256
 
function getCachedRegex(pattern: string): RegExp | null {
  const cached = regexCache.get(pattern)
  if (cached) return cached
  try {
    const re = new RegExp(pattern)
    if (regexCache.size >= REGEX_CACHE_MAX) {
      const first = regexCache.keys().next().value
      if (first !== undefined) regexCache.delete(first)
    }
    regexCache.set(pattern, re)
    return re
  } catch {
    return null   // invalid regex -> fail closed
  }
}
const MAX_REGEX_LENGTH = 512
const REGEX_CACHE_MAX = 256
 
function getCachedRegex(pattern: string): RegExp | null {
  const cached = regexCache.get(pattern)
  if (cached) return cached
  try {
    const re = new RegExp(pattern)
    if (regexCache.size >= REGEX_CACHE_MAX) {
      const first = regexCache.keys().next().value
      if (first !== undefined) regexCache.delete(first)
    }
    regexCache.set(pattern, re)
    return re
  } catch {
    return null   // invalid regex -> fail closed
  }
}

Condition Depth Limit

Nested condition groups are limited to 10 levels deep. Deeper nesting returns false (fail closed), preventing stack overflow from deeply nested or recursive conditions.

const MAX_CONDITION_DEPTH = 10
 
function evalConditionGroup(req, group, depth = 0): boolean {
  if (depth >= MAX_CONDITION_DEPTH) {
    return false  // Deny when nesting is too deep
  }
  // ...evaluate children with depth + 1
}
const MAX_CONDITION_DEPTH = 10
 
function evalConditionGroup(req, group, depth = 0): boolean {
  if (depth >= MAX_CONDITION_DEPTH) {
    return false  // Deny when nesting is too deep
  }
  // ...evaluate children with depth + 1
}

The same limit applies to explain() traces (MAX_TRACE_DEPTH = 10).

Operator Type Safety

Each operator validates its operand types before comparing:

OperatorRequired TypesOn Type Mismatch
gt, gte, lt, lteBoth numberReturns false
starts_with, ends_withBoth stringReturns false
matchesBoth stringReturns false
containsArray or string fieldReturns false
subset_of, superset_ofBoth ArrayReturns false
in, ninValue must be ArrayReturns false/true
exists(any)Checks != null && != undefined
not_exists(any)Checks == null || == undefined

Type mismatches never throw -- they return false (fail closed for allow rules) or true where appropriate (fail closed for deny rules depends on the operator).

Dynamic Variable Resolution

Condition values starting with $ are resolved against the request at evaluation time:

function resolveValue(req: AccessRequest, value: AttributeValue): AttributeValue {
  if (typeof value === 'string' && value.startsWith('$')) {
    return resolve(req, value.slice(1))  // strip '$' and resolve
  }
  return value
}
function resolveValue(req: AccessRequest, value: AttributeValue): AttributeValue {
  if (typeof value === 'string' && value.startsWith('$')) {
    return resolve(req, value.slice(1))  // strip '$' and resolve
  }
  return value
}

The $ prefix triggers resolution through the same secure resolve() function, so $subject.id becomes the subject's ID and $resource.attributes.ownerId becomes the resource's ownerId attribute. The same security protections (allowed roots, blocked segments) apply.

Safe Defaults

  • Deny by default: No permission granted unless explicitly allowed (defaultEffect: 'deny')
  • Missing attributes: Unresolved fields return null, causing comparisons to fail safely
  • Unknown subjects: Have no roles and empty attributes (deny by default)
  • Cache TTL: Prevents indefinite stale data (default 60 seconds)
  • Cross-policy AND: A deny from ANY policy blocks the request (defense in depth)

Testing Strategies

Unit test roles and policies

__tests__/access.test.ts
import { describe, it, expect } from 'vitest'
import { Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { viewer, editor, admin, ownerPolicy } from '../src/access'
 
function createTestEngine() {
  const adapter = new MemoryAdapter({
    roles: [viewer, editor, admin],
    assignments: {
      'alice': ['viewer'],
      'bob': ['editor'],
      'charlie': ['admin'],
    },
    policies: [ownerPolicy],
  })
  return new Engine({ adapter, cacheTTL: 0 })
}
 
describe('RBAC', () => {
  const engine = createTestEngine()
 
  it('viewer can read posts', async () => {
    const result = await engine.can('alice', 'read', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(true)
  })
 
  it('viewer cannot create posts', async () => {
    const result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)
  })
 
  it('editor inherits viewer permissions', async () => {
    const result = await engine.can('bob', 'read', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(true)
  })
 
  it('admin can manage users', async () => {
    const result = await engine.can('charlie', 'manage', {
      type: 'user', attributes: {},
    })
    expect(result).toBe(true)
  })
})
 
describe('ABAC - owner policy', () => {
  const engine = createTestEngine()
 
  it('editor can update own post', async () => {
    const result = await engine.can('bob', 'update', {
      type: 'post', id: 'post-1',
      attributes: { ownerId: 'bob' },
    })
    expect(result).toBe(true)
  })
 
  it('editor cannot update other post', async () => {
    const result = await engine.can('bob', 'update', {
      type: 'post', id: 'post-2',
      attributes: { ownerId: 'alice' },
    })
    expect(result).toBe(false)
  })
 
  it('admin can update any post', async () => {
    const result = await engine.can('charlie', 'update', {
      type: 'post', id: 'post-2',
      attributes: { ownerId: 'alice' },
    })
    expect(result).toBe(true)
  })
})
__tests__/access.test.ts
import { describe, it, expect } from 'vitest'
import { Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { viewer, editor, admin, ownerPolicy } from '../src/access'
 
function createTestEngine() {
  const adapter = new MemoryAdapter({
    roles: [viewer, editor, admin],
    assignments: {
      'alice': ['viewer'],
      'bob': ['editor'],
      'charlie': ['admin'],
    },
    policies: [ownerPolicy],
  })
  return new Engine({ adapter, cacheTTL: 0 })
}
 
describe('RBAC', () => {
  const engine = createTestEngine()
 
  it('viewer can read posts', async () => {
    const result = await engine.can('alice', 'read', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(true)
  })
 
  it('viewer cannot create posts', async () => {
    const result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)
  })
 
  it('editor inherits viewer permissions', async () => {
    const result = await engine.can('bob', 'read', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(true)
  })
 
  it('admin can manage users', async () => {
    const result = await engine.can('charlie', 'manage', {
      type: 'user', attributes: {},
    })
    expect(result).toBe(true)
  })
})
 
describe('ABAC - owner policy', () => {
  const engine = createTestEngine()
 
  it('editor can update own post', async () => {
    const result = await engine.can('bob', 'update', {
      type: 'post', id: 'post-1',
      attributes: { ownerId: 'bob' },
    })
    expect(result).toBe(true)
  })
 
  it('editor cannot update other post', async () => {
    const result = await engine.can('bob', 'update', {
      type: 'post', id: 'post-2',
      attributes: { ownerId: 'alice' },
    })
    expect(result).toBe(false)
  })
 
  it('admin can update any post', async () => {
    const result = await engine.can('charlie', 'update', {
      type: 'post', id: 'post-2',
      attributes: { ownerId: 'alice' },
    })
    expect(result).toBe(true)
  })
})

Set cacheTTL: 0 in tests to disable caching and ensure fresh evaluations. Note that engine.can() returns boolean, not Decision.

Test with check() for Decision details

When you need to inspect the Decision object (effect, rule, reason, etc.), use engine.check() instead of engine.can():

it('returns deny decision with reason', async () => {
  const engine = createTestEngine()
  const decision = await engine.check('bob', 'update', {
    type: 'post', id: 'post-2',
    attributes: { ownerId: 'alice' },
  })
 
  expect(decision.allowed).toBe(false)
  expect(decision.effect).toBe('deny')
  expect(decision.policy).toBe('owner-restrictions')
  expect(decision.reason).toContain('deny-non-owner-update')
  expect(decision.duration).toBeGreaterThanOrEqual(0)
  expect(decision.timestamp).toBeGreaterThan(0)
})
it('returns deny decision with reason', async () => {
  const engine = createTestEngine()
  const decision = await engine.check('bob', 'update', {
    type: 'post', id: 'post-2',
    attributes: { ownerId: 'alice' },
  })
 
  expect(decision.allowed).toBe(false)
  expect(decision.effect).toBe('deny')
  expect(decision.policy).toBe('owner-restrictions')
  expect(decision.reason).toContain('deny-non-owner-update')
  expect(decision.duration).toBeGreaterThanOrEqual(0)
  expect(decision.timestamp).toBeGreaterThan(0)
})

Test scoped permissions

describe('multi-tenant', () => {
  it('scoped roles apply per tenant', async () => {
    const adapter = new MemoryAdapter({
      roles: [viewer, admin],
      assignments: { 'alice': ['viewer'] },
    })
    // Add scoped assignments programmatically
    await adapter.assignRole('alice', 'admin', 'acme')
 
    const engine = new Engine({ adapter, cacheTTL: 0 })
 
    // With scope: admin in acme
    const acme = await engine.can('alice', 'manage',
      { type: 'user', attributes: {} },
      undefined, 'acme')
    expect(acme).toBe(true)
 
    // Without scope: just viewer
    const noScope = await engine.can('alice', 'manage',
      { type: 'user', attributes: {} })
    expect(noScope).toBe(false)
 
    // Different scope: just viewer
    const globex = await engine.can('alice', 'manage',
      { type: 'user', attributes: {} },
      undefined, 'globex')
    expect(globex).toBe(false)
  })
})
describe('multi-tenant', () => {
  it('scoped roles apply per tenant', async () => {
    const adapter = new MemoryAdapter({
      roles: [viewer, admin],
      assignments: { 'alice': ['viewer'] },
    })
    // Add scoped assignments programmatically
    await adapter.assignRole('alice', 'admin', 'acme')
 
    const engine = new Engine({ adapter, cacheTTL: 0 })
 
    // With scope: admin in acme
    const acme = await engine.can('alice', 'manage',
      { type: 'user', attributes: {} },
      undefined, 'acme')
    expect(acme).toBe(true)
 
    // Without scope: just viewer
    const noScope = await engine.can('alice', 'manage',
      { type: 'user', attributes: {} })
    expect(noScope).toBe(false)
 
    // Different scope: just viewer
    const globex = await engine.can('alice', 'manage',
      { type: 'user', attributes: {} },
      undefined, 'globex')
    expect(globex).toBe(false)
  })
})

Test batch permissions

it('generates permission map for client', async () => {
  const engine = createTestEngine()
 
  const map = await engine.permissions('bob', [
    { action: 'create', resource: 'post' },
    { action: 'read', resource: 'post' },
    { action: 'delete', resource: 'post' },
    { action: 'manage', resource: 'user' },
  ])
 
  expect(map).toEqual({
    'create:post': true,   // editor can create
    'read:post': true,     // editor inherits viewer's read
    'delete:post': false,  // editor cannot delete
    'manage:user': false,  // editor cannot manage users
  })
})
it('generates permission map for client', async () => {
  const engine = createTestEngine()
 
  const map = await engine.permissions('bob', [
    { action: 'create', resource: 'post' },
    { action: 'read', resource: 'post' },
    { action: 'delete', resource: 'post' },
    { action: 'manage', resource: 'user' },
  ])
 
  expect(map).toEqual({
    'create:post': true,   // editor can create
    'read:post': true,     // editor inherits viewer's read
    'delete:post': false,  // editor cannot delete
    'manage:user': false,  // editor cannot manage users
  })
})

Test with explain for debugging

it('explains why permission is denied', async () => {
  const engine = createTestEngine()
  const result = await engine.explain('bob', 'update', {
    type: 'post', id: 'post-2',
    attributes: { ownerId: 'alice' },
  })
 
  expect(result.decision.allowed).toBe(false)
  expect(result.summary).toContain('deny-non-owner-update')
 
  // Inspect the trace
  expect(result.subject.id).toBe('bob')
  expect(result.subject.roles).toContain('editor')
  expect(result.request.action).toBe('update')
  expect(result.request.resourceType).toBe('post')
 
  // Find the denying policy trace
  const policyTrace = result.policies.find(
    p => p.policyId === 'owner-restrictions'
  )
  expect(policyTrace?.result).toBe('deny')
  expect(policyTrace?.decidingRuleId).toBe('deny-non-owner-update')
})
it('explains why permission is denied', async () => {
  const engine = createTestEngine()
  const result = await engine.explain('bob', 'update', {
    type: 'post', id: 'post-2',
    attributes: { ownerId: 'alice' },
  })
 
  expect(result.decision.allowed).toBe(false)
  expect(result.summary).toContain('deny-non-owner-update')
 
  // Inspect the trace
  expect(result.subject.id).toBe('bob')
  expect(result.subject.roles).toContain('editor')
  expect(result.request.action).toBe('update')
  expect(result.request.resourceType).toBe('post')
 
  // Find the denying policy trace
  const policyTrace = result.policies.find(
    p => p.policyId === 'owner-restrictions'
  )
  expect(policyTrace?.result).toBe('deny')
  expect(policyTrace?.decidingRuleId).toBe('deny-non-owner-update')
})

Test validation at startup

describe('validation', () => {
  it('detects dangling inherits', () => {
    const broken = access.defineRole('broken')
      .inherits('nonexistent')
      .build()
 
    const result = access.validateRoles([viewer, broken])
    expect(result.valid).toBe(false)
    expect(result.issues).toContainEqual(
      expect.objectContaining({
        code: 'DANGLING_INHERIT',
        roleId: 'broken',
      })
    )
  })
 
  it('detects circular inheritance', () => {
    const a = access.defineRole('a').inherits('b').grant('read', 'post').build()
    const b = access.defineRole('b').inherits('a').grant('read', 'post').build()
 
    const result = access.validateRoles([a, b])
    expect(result.valid).toBe(true) // warnings don't block validity
    expect(result.issues).toContainEqual(
      expect.objectContaining({ code: 'CIRCULAR_INHERIT' })
    )
  })
 
  it('validates untrusted policy JSON', () => {
    const bad = { id: '', algorithm: 'invalid' }
    const result = access.validatePolicy(bad)
 
    expect(result.valid).toBe(false)
    expect(result.issues.map(i => i.code)).toContain('MISSING_FIELD')
    expect(result.issues.map(i => i.code)).toContain('INVALID_ALGORITHM')
  })
})
describe('validation', () => {
  it('detects dangling inherits', () => {
    const broken = access.defineRole('broken')
      .inherits('nonexistent')
      .build()
 
    const result = access.validateRoles([viewer, broken])
    expect(result.valid).toBe(false)
    expect(result.issues).toContainEqual(
      expect.objectContaining({
        code: 'DANGLING_INHERIT',
        roleId: 'broken',
      })
    )
  })
 
  it('detects circular inheritance', () => {
    const a = access.defineRole('a').inherits('b').grant('read', 'post').build()
    const b = access.defineRole('b').inherits('a').grant('read', 'post').build()
 
    const result = access.validateRoles([a, b])
    expect(result.valid).toBe(true) // warnings don't block validity
    expect(result.issues).toContainEqual(
      expect.objectContaining({ code: 'CIRCULAR_INHERIT' })
    )
  })
 
  it('validates untrusted policy JSON', () => {
    const bad = { id: '', algorithm: 'invalid' }
    const result = access.validatePolicy(bad)
 
    expect(result.valid).toBe(false)
    expect(result.issues.map(i => i.code)).toContain('MISSING_FIELD')
    expect(result.issues.map(i => i.code)).toContain('INVALID_ALGORITHM')
  })
})

Test hooks

describe('hooks', () => {
  it('calls afterEvaluate on every check', async () => {
    const logs: string[] = []
    const adapter = new MemoryAdapter({
      roles: [viewer],
      assignments: { 'alice': ['viewer'] },
    })
    const engine = new Engine({
      adapter,
      cacheTTL: 0,
      hooks: {
        afterEvaluate: async (req, decision) => {
          logs.push(`${req.subject.id}:${req.action}:${decision.effect}`)
        },
      },
    })
 
    await engine.can('alice', 'read', { type: 'post', attributes: {} })
    expect(logs).toEqual(['alice:read:allow'])
  })
 
  it('calls onDeny only when denied', async () => {
    const denied: string[] = []
    const adapter = new MemoryAdapter({
      roles: [viewer],
      assignments: { 'alice': ['viewer'] },
    })
    const engine = new Engine({
      adapter,
      cacheTTL: 0,
      hooks: {
        onDeny: async (req) => {
          denied.push(`${req.action}:${req.resource.type}`)
        },
      },
    })
 
    await engine.can('alice', 'read', { type: 'post', attributes: {} })
    expect(denied).toHaveLength(0)  // allowed, no onDeny
 
    await engine.can('alice', 'delete', { type: 'post', attributes: {} })
    expect(denied).toEqual(['delete:post'])  // denied, onDeny called
  })
 
  it('calls onError when adapter fails', async () => {
    const errors: Error[] = []
    const adapter = new MemoryAdapter()
    // Simulate adapter failure
    adapter.getSubjectRoles = async () => { throw new Error('DB down') }
 
    const engine = new Engine({
      adapter,
      cacheTTL: 0,
      hooks: { onError: async (err) => { errors.push(err) } },
    })
 
    const result = await engine.can('alice', 'read', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)  // fail closed
    expect(errors[0]?.message).toBe('DB down')
  })
})
describe('hooks', () => {
  it('calls afterEvaluate on every check', async () => {
    const logs: string[] = []
    const adapter = new MemoryAdapter({
      roles: [viewer],
      assignments: { 'alice': ['viewer'] },
    })
    const engine = new Engine({
      adapter,
      cacheTTL: 0,
      hooks: {
        afterEvaluate: async (req, decision) => {
          logs.push(`${req.subject.id}:${req.action}:${decision.effect}`)
        },
      },
    })
 
    await engine.can('alice', 'read', { type: 'post', attributes: {} })
    expect(logs).toEqual(['alice:read:allow'])
  })
 
  it('calls onDeny only when denied', async () => {
    const denied: string[] = []
    const adapter = new MemoryAdapter({
      roles: [viewer],
      assignments: { 'alice': ['viewer'] },
    })
    const engine = new Engine({
      adapter,
      cacheTTL: 0,
      hooks: {
        onDeny: async (req) => {
          denied.push(`${req.action}:${req.resource.type}`)
        },
      },
    })
 
    await engine.can('alice', 'read', { type: 'post', attributes: {} })
    expect(denied).toHaveLength(0)  // allowed, no onDeny
 
    await engine.can('alice', 'delete', { type: 'post', attributes: {} })
    expect(denied).toEqual(['delete:post'])  // denied, onDeny called
  })
 
  it('calls onError when adapter fails', async () => {
    const errors: Error[] = []
    const adapter = new MemoryAdapter()
    // Simulate adapter failure
    adapter.getSubjectRoles = async () => { throw new Error('DB down') }
 
    const engine = new Engine({
      adapter,
      cacheTTL: 0,
      hooks: { onError: async (err) => { errors.push(err) } },
    })
 
    const result = await engine.can('alice', 'read', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)  // fail closed
    expect(errors[0]?.message).toBe('DB down')
  })
})

Test admin API operations

describe('admin', () => {
  it('assigns and revokes roles', async () => {
    const adapter = new MemoryAdapter({
      roles: [viewer, editor],
      assignments: { 'alice': ['viewer'] },
    })
    const engine = new Engine({ adapter, cacheTTL: 0 })
 
    // Initially viewer only
    let result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)
 
    // Promote to editor
    await engine.admin.assignRole('alice', 'editor')
 
    result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(true)  // cache was invalidated
 
    // Revoke editor
    await engine.admin.revokeRole('alice', 'editor')
 
    result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)  // back to viewer
  })
 
  it('saves and retrieves policies', async () => {
    const adapter = new MemoryAdapter()
    const engine = new Engine({ adapter, cacheTTL: 0 })
 
    await engine.admin.savePolicy(ownerPolicy)
    const retrieved = await engine.admin.getPolicy('owner-restrictions')
    expect(retrieved?.id).toBe('owner-restrictions')
 
    const all = await engine.admin.listPolicies()
    expect(all).toHaveLength(1)
 
    await engine.admin.deletePolicy('owner-restrictions')
    const afterDelete = await engine.admin.listPolicies()
    expect(afterDelete).toHaveLength(0)
  })
})
describe('admin', () => {
  it('assigns and revokes roles', async () => {
    const adapter = new MemoryAdapter({
      roles: [viewer, editor],
      assignments: { 'alice': ['viewer'] },
    })
    const engine = new Engine({ adapter, cacheTTL: 0 })
 
    // Initially viewer only
    let result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)
 
    // Promote to editor
    await engine.admin.assignRole('alice', 'editor')
 
    result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(true)  // cache was invalidated
 
    // Revoke editor
    await engine.admin.revokeRole('alice', 'editor')
 
    result = await engine.can('alice', 'create', {
      type: 'post', attributes: {},
    })
    expect(result).toBe(false)  // back to viewer
  })
 
  it('saves and retrieves policies', async () => {
    const adapter = new MemoryAdapter()
    const engine = new Engine({ adapter, cacheTTL: 0 })
 
    await engine.admin.savePolicy(ownerPolicy)
    const retrieved = await engine.admin.getPolicy('owner-restrictions')
    expect(retrieved?.id).toBe('owner-restrictions')
 
    const all = await engine.admin.listPolicies()
    expect(all).toHaveLength(1)
 
    await engine.admin.deletePolicy('owner-restrictions')
    const afterDelete = await engine.admin.listPolicies()
    expect(afterDelete).toHaveLength(0)
  })
})

Test server middleware (Express example)

import express from 'express'
import request from 'supertest'
import { accessMiddleware, guard } from '@gentleduck/iam/integrations/express'
 
describe('Express middleware', () => {
  function createApp() {
    const app = express()
    app.use((req, _res, next) => {
      // Simulate auth middleware
      ;(req as any).userId = 'alice'
      next()
    })
    app.use(accessMiddleware({
      engine,
      getSubjectId: (req) => (req as any).userId,
    }))
 
    app.get('/api/posts', guard('read', 'post'), (_req, res) => {
      res.json({ posts: [] })
    })
    app.delete('/api/posts/:id', guard('delete', 'post'), (_req, res) => {
      res.json({ deleted: true })
    })
 
    return app
  }
 
  it('allows authorized requests', async () => {
    const app = createApp()
    const res = await request(app).get('/api/posts')
    expect(res.status).toBe(200)
  })
 
  it('blocks unauthorized requests', async () => {
    const app = createApp()
    const res = await request(app).delete('/api/posts/1')
    expect(res.status).toBe(403)
  })
})
import express from 'express'
import request from 'supertest'
import { accessMiddleware, guard } from '@gentleduck/iam/integrations/express'
 
describe('Express middleware', () => {
  function createApp() {
    const app = express()
    app.use((req, _res, next) => {
      // Simulate auth middleware
      ;(req as any).userId = 'alice'
      next()
    })
    app.use(accessMiddleware({
      engine,
      getSubjectId: (req) => (req as any).userId,
    }))
 
    app.get('/api/posts', guard('read', 'post'), (_req, res) => {
      res.json({ posts: [] })
    })
    app.delete('/api/posts/:id', guard('delete', 'post'), (_req, res) => {
      res.json({ deleted: true })
    })
 
    return app
  }
 
  it('allows authorized requests', async () => {
    const app = createApp()
    const res = await request(app).get('/api/posts')
    expect(res.status).toBe(200)
  })
 
  it('blocks unauthorized requests', async () => {
    const app = createApp()
    const res = await request(app).delete('/api/posts/1')
    expect(res.status).toBe(403)
  })
})

Testing Best Practices

PracticeWhy
Use cacheTTL: 0 in testsEnsures every check hits the adapter fresh
Use MemoryAdapter for unit testsNo database setup needed
Use engine.can() for simple pass/failReturns boolean, cleaner assertions
Use engine.check() when inspecting decisionsReturns full Decision object
Use engine.explain() for debugging failuresShows exactly which rule/condition failed
Test both allowed and denied casesEnsures deny rules are working correctly
Test inheritance chainsVerify child roles get parent permissions
Test scoped roles separatelyVerify scope isolation
Test hooks with mock callbacksVerify audit/error logging works
Test admin operations with can() after mutationVerify cache invalidation

Production Checklist

Before shipping BlogDuck:

  • Use createAccessConfig with as const for type safety
  • Replace MemoryAdapter with PrismaAdapter or DrizzleAdapter
  • Call validateRoles() and validatePolicy() at startup
  • Set appropriate cacheTTL (default 60s is good for most apps)
  • Set maxCacheSize based on your concurrent user count
  • Add afterEvaluate hook for audit logging
  • Add onDeny hook for security monitoring
  • Add onError hook for error monitoring
  • Protect admin API endpoints with authorization
  • Validate dynamic policies with validatePolicy() before saving
  • Write tests for every role, policy, and scoped permission
  • Test both allowed and denied cases
  • Server enforces permissions on every request (Chapter 6)
  • Client uses permission maps for UI only (Chapter 7)
  • Never trust client-side permission checks for security

Complete Production Setup

Full production src/access.ts
import { createAccessConfig } from '@gentleduck/iam'
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { PrismaClient } from '@prisma/client'
 
// 1. Type-safe config
export const access = createAccessConfig({
  actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
  resources: ['post', 'comment', 'user', 'dashboard'] as const,
  scopes: ['acme', 'globex'] as const,
})
 
// 2. Roles
export const viewer = access.defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
 
export const editor = access.defineRole('editor')
  .inherits('viewer')
  .grant('create', 'post').grant('update', 'post')
  .grant('create', 'comment').grant('update', 'comment')
  .build()
 
export const admin = access.defineRole('admin')
  .inherits('editor')
  .grant('delete', 'post').grant('delete', 'comment')
  .grant('manage', 'user').grant('manage', 'dashboard')
  .build()
 
// 3. Policies
export const ownerPolicy = access.policy('owner-restrictions')
  .name('Owner Restrictions')
  .algorithm('deny-overrides')
  .rule('deny-non-owner-update', r => r
    .deny().on('update', 'delete').of('post').priority(100)
    .when(w => w
      .check('resource.attributes.ownerId', 'neq', '$subject.id')
      .not(n => n.role('admin'))
    )
  )
  .build()
 
// 4. Validation
const roleCheck = access.validateRoles([viewer, editor, admin])
if (!roleCheck.valid) throw new Error(roleCheck.issues.map(i => i.message).join(', '))
 
const policyCheck = access.validatePolicy(ownerPolicy)
if (!policyCheck.valid) throw new Error(policyCheck.issues.map(i => i.message).join(', '))
 
// 5. Database adapter
const prisma = new PrismaClient()
const adapter = new PrismaAdapter(prisma)
 
// 6. Engine with hooks
export const engine = access.createEngine({
  adapter,
  cacheTTL: 60,
  maxCacheSize: 1000,
  hooks: {
    afterEvaluate: async (request, decision) => {
      console.log(`[audit] ${request.subject.id} ${decision.effect} ${request.action}:${request.resource.type}`)
    },
    onDeny: async (request, decision) => {
      console.log(`[denied] ${request.subject.id} -> ${request.action}:${request.resource.type}: ${decision.reason}`)
    },
    onError: async (error) => {
      console.error('[auth-error]', error)
    },
  },
})
 
// 7. Typed permission checks for client
export const appChecks = access.checks([
  { action: 'create', resource: 'post' },
  { action: 'read', resource: 'post' },
  { action: 'update', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'dashboard' },
  { action: 'manage', resource: 'user' },
] as const)
import { createAccessConfig } from '@gentleduck/iam'
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { PrismaClient } from '@prisma/client'
 
// 1. Type-safe config
export const access = createAccessConfig({
  actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
  resources: ['post', 'comment', 'user', 'dashboard'] as const,
  scopes: ['acme', 'globex'] as const,
})
 
// 2. Roles
export const viewer = access.defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
 
export const editor = access.defineRole('editor')
  .inherits('viewer')
  .grant('create', 'post').grant('update', 'post')
  .grant('create', 'comment').grant('update', 'comment')
  .build()
 
export const admin = access.defineRole('admin')
  .inherits('editor')
  .grant('delete', 'post').grant('delete', 'comment')
  .grant('manage', 'user').grant('manage', 'dashboard')
  .build()
 
// 3. Policies
export const ownerPolicy = access.policy('owner-restrictions')
  .name('Owner Restrictions')
  .algorithm('deny-overrides')
  .rule('deny-non-owner-update', r => r
    .deny().on('update', 'delete').of('post').priority(100)
    .when(w => w
      .check('resource.attributes.ownerId', 'neq', '$subject.id')
      .not(n => n.role('admin'))
    )
  )
  .build()
 
// 4. Validation
const roleCheck = access.validateRoles([viewer, editor, admin])
if (!roleCheck.valid) throw new Error(roleCheck.issues.map(i => i.message).join(', '))
 
const policyCheck = access.validatePolicy(ownerPolicy)
if (!policyCheck.valid) throw new Error(policyCheck.issues.map(i => i.message).join(', '))
 
// 5. Database adapter
const prisma = new PrismaClient()
const adapter = new PrismaAdapter(prisma)
 
// 6. Engine with hooks
export const engine = access.createEngine({
  adapter,
  cacheTTL: 60,
  maxCacheSize: 1000,
  hooks: {
    afterEvaluate: async (request, decision) => {
      console.log(`[audit] ${request.subject.id} ${decision.effect} ${request.action}:${request.resource.type}`)
    },
    onDeny: async (request, decision) => {
      console.log(`[denied] ${request.subject.id} -> ${request.action}:${request.resource.type}: ${decision.reason}`)
    },
    onError: async (error) => {
      console.error('[auth-error]', error)
    },
  },
})
 
// 7. Typed permission checks for client
export const appChecks = access.checks([
  { action: 'create', resource: 'post' },
  { action: 'read', resource: 'post' },
  { action: 'update', resource: 'post' },
  { action: 'delete', resource: 'post' },
  { action: 'manage', resource: 'dashboard' },
  { action: 'manage', resource: 'user' },
] as const)

Chapter 8 FAQ

What happens if I forget as const?

Without as const, TypeScript widens the array types to string[]. The config will still work at runtime, but you lose compile-time checking -- any string will be accepted as an action or resource. Always use as const with createAccessConfig to get the full type safety benefits.

Which database adapter should I use?

Use PrismaAdapter if you already use Prisma. Use DrizzleAdapter if you already use Drizzle. Use HttpAdapter in microservice architectures where a central service manages authorization. Use MemoryAdapter for prototyping, testing, and simple apps that do not need persistence. You can also implement your own adapter by implementing the Adapter interface which extends PolicyStore, RoleStore, and SubjectStore.

How should I size the subject cache?

Set maxCacheSize to roughly your peak concurrent user count. The default of 1000 works for most applications. If you have 10,000 concurrent users, set it to 10,000. Each cached entry is small (role list + scoped roles + attributes), so memory usage is minimal. The cache uses LRU eviction, so inactive users are dropped first. The policy and role caches always have max size 1 (single entry each).

Can I write a custom adapter?

Yes. Implement the Adapter interface which extends PolicyStore, RoleStore, and SubjectStore. That is 14 methods total (4 for PolicyStore, 4 for RoleStore, 6 for SubjectStore). getSubjectScopedRoles is optional. See the MemoryAdapter source code as a reference implementation -- it is the simplest adapter and shows all required methods. Make sure getSubjectRoles() returns only base (unscoped) roles, savePolicy/saveRole() upserts, and setSubjectAttributes() merges rather than replaces.

How do I migrate from MemoryAdapter to a database?

Replace the adapter in your engine configuration. The engine API does not change -- all your roles, policies, and permission checks work exactly the same. Create the database tables (using the Prisma schema or Drizzle schema above), seed them with your existing role and policy definitions, then swap the adapter. Your application code stays the same.

When should I use validatePolicy() vs validateRoles()?

Use validateRoles() at startup to check your static role definitions for configuration mistakes (dangling inherits, circular inheritance, empty roles). Use validatePolicy() whenever you load a policy from an untrusted source -- a database, an API, an admin dashboard, or a JSON file. It deeply validates the entire structure: required fields, valid algorithm, rule shapes, condition operators, and group structure. Always call validatePolicy() before engine.admin.savePolicy() with external data.

Do I need to manually invalidate caches?

Not if you use the engine.admin API -- it automatically invalidates the correct caches after each mutation. Manual invalidation is needed when you modify the database directly (bypassing the admin API), or in multi-instance deployments where another server changed the data. In those cases, use engine.invalidate() to clear everything, or the more targeted methods: invalidateSubject(id), invalidatePolicies(), invalidateRoles().

What does "fail closed" mean?

Fail closed means that when an error or unexpected condition occurs, the system denies access rather than allowing it. In duck-iam: adapter failures return deny, condition depth limit returns deny, invalid regex returns deny, prototype pollution paths return null (which fails comparisons), and unknown subjects have no roles (deny by default). This is the opposite of "fail open" where errors would grant access -- fail open is dangerous for authorization.

What are InferAction, InferResource, and InferScope?

These are utility types that extract the literal string union from your config object. InferAction&lt;typeof config&gt; gives you 'create' | 'read' | 'update' | 'delete'. Use them when you need to type function parameters, API handlers, or custom adapter generics against your specific config without importing the config object itself.

How do I authenticate the HttpAdapter?

Pass a headers function that returns the auth headers. The function can be async, so you can fetch fresh tokens dynamically: headers: async () => ({ Authorization: 'Bearer ' + await getServiceToken() }). The function is called on every request, so token rotation works automatically. On the server side, protect the admin router endpoints with your own auth middleware before mounting them.


You have completed the duck-iam course. BlogDuck now has a full authorization system with roles, policies, scoped multi-tenancy, server middleware, client libraries, and production-grade type safety.

Go back to the Course Overview or explore the full documentation.