Skip to main content

custom adapter

Implement the Adapter interface to connect duck-iam to any storage backend — DynamoDB, MongoDB, Firestore, Supabase, or your in-house DB.

The Adapter interface

Any object satisfying this interface can plug into Engine:

import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
 
interface Adapter<TAction, TResource, TRole, TScope> {
  // PolicyStore
  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>
 
  // RoleStore
  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>
 
  // SubjectStore
  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>
}
import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
 
interface Adapter<TAction, TResource, TRole, TScope> {
  // PolicyStore
  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>
 
  // RoleStore
  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>
 
  // SubjectStore
  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>
}

getSubjectScopedRoles is optional. If your application doesn't use scoped roles, omit it. When absent, the engine treats scoped assignments as empty and continues with global role evaluation only.


Example: DynamoDB sketch

import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
import type { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
 
export class DynamoAdapter implements Adapter {
  constructor(
    private db: DynamoDBDocument,
    private table: string,
  ) {}
 
  async listPolicies(): Promise<Policy[]> {
    const out = await this.db.scan({
      TableName: this.table,
      FilterExpression: '#k = :k',
      ExpressionAttributeNames: { '#k': 'kind' },
      ExpressionAttributeValues: { ':k': 'policy' },
    })
    return (out.Items ?? []).map((i) => i.value as Policy)
  }
 
  async getPolicy(id: string): Promise<Policy | null> {
    const out = await this.db.get({ TableName: this.table, Key: { pk: `policy#${id}` } })
    return (out.Item?.value as Policy) ?? null
  }
 
  async savePolicy(p: Policy): Promise<void> {
    await this.db.put({
      TableName: this.table,
      Item: { pk: `policy#${p.id}`, kind: 'policy', value: p },
    })
  }
 
  async deletePolicy(id: string): Promise<void> {
    await this.db.delete({ TableName: this.table, Key: { pk: `policy#${id}` } })
  }
 
  // ... implement remaining methods for roles and subjects
}
import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
import type { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
 
export class DynamoAdapter implements Adapter {
  constructor(
    private db: DynamoDBDocument,
    private table: string,
  ) {}
 
  async listPolicies(): Promise<Policy[]> {
    const out = await this.db.scan({
      TableName: this.table,
      FilterExpression: '#k = :k',
      ExpressionAttributeNames: { '#k': 'kind' },
      ExpressionAttributeValues: { ':k': 'policy' },
    })
    return (out.Items ?? []).map((i) => i.value as Policy)
  }
 
  async getPolicy(id: string): Promise<Policy | null> {
    const out = await this.db.get({ TableName: this.table, Key: { pk: `policy#${id}` } })
    return (out.Item?.value as Policy) ?? null
  }
 
  async savePolicy(p: Policy): Promise<void> {
    await this.db.put({
      TableName: this.table,
      Item: { pk: `policy#${p.id}`, kind: 'policy', value: p },
    })
  }
 
  async deletePolicy(id: string): Promise<void> {
    await this.db.delete({ TableName: this.table, Key: { pk: `policy#${id}` } })
  }
 
  // ... implement remaining methods for roles and subjects
}

Example: MongoDB sketch

import type { Adapter, Policy } from '@gentleduck/iam'
import type { Db } from 'mongodb'
 
export class MongoAdapter implements Adapter {
  constructor(private db: Db) {}
 
  async listPolicies(): Promise<Policy[]> {
    return this.db.collection<Policy>('access_policies').find({}).toArray()
  }
 
  async getPolicy(id: string): Promise<Policy | null> {
    return this.db.collection<Policy>('access_policies').findOne({ id })
  }
 
  async savePolicy(p: Policy): Promise<void> {
    await this.db.collection('access_policies').replaceOne({ id: p.id }, p, { upsert: true })
  }
 
  async deletePolicy(id: string): Promise<void> {
    await this.db.collection('access_policies').deleteOne({ id })
  }
 
  // ... implement remaining methods
}
import type { Adapter, Policy } from '@gentleduck/iam'
import type { Db } from 'mongodb'
 
export class MongoAdapter implements Adapter {
  constructor(private db: Db) {}
 
  async listPolicies(): Promise<Policy[]> {
    return this.db.collection<Policy>('access_policies').find({}).toArray()
  }
 
  async getPolicy(id: string): Promise<Policy | null> {
    return this.db.collection<Policy>('access_policies').findOne({ id })
  }
 
  async savePolicy(p: Policy): Promise<void> {
    await this.db.collection('access_policies').replaceOne({ id: p.id }, p, { upsert: true })
  }
 
  async deletePolicy(id: string): Promise<void> {
    await this.db.collection('access_policies').deleteOne({ id })
  }
 
  // ... implement remaining methods
}

Implementation guidelines

Idempotency

assignRole should be idempotent. Use unique constraints, set semantics (Redis), or INSERT ... ON CONFLICT DO NOTHING (SQL). Calling assignRole(subjectId, roleId, scope) twice with the same arguments must not throw.

Scoped roles

If you store scoped and unscoped assignments together, getSubjectRoles should return only unscoped assignments, and getSubjectScopedRoles should return only scoped. The engine merges them based on request scope.

The MemoryAdapter actually returns both from getSubjectRoles (which then gets deduplicated by the engine) — both shapes work. The cleaner contract is split.

Attribute merging

setSubjectAttributes(id, { foo: 'bar' }) should merge, not replace. To delete an attribute, set it to null and treat that as a tombstone in your storage layer.

Atomic writes

For high-contention attribute writes, use database transactions (SELECT ... FOR UPDATE in SQL, WATCH/MULTI/EXEC in Redis, transactions in MongoDB). The built-in adapters do not wrap reads + merges in transactions, so concurrent writes may lose data.


Testing custom adapters

Use MemoryAdapter as a behavioral reference. Your adapter should produce identical engine results for the same inputs.

import { Engine } from '@gentleduck/iam'
import { YourAdapter } from './your-adapter'
 
const adapter = new YourAdapter(/* config */)
 
// Seed
await adapter.saveRole({
  id: 'editor',
  name: 'Editor',
  permissions: [
    { action: 'read', resource: '*' },
    { action: 'update', resource: 'post' },
  ],
})
await adapter.assignRole('user-1', 'editor')
 
// Test
const engine = new Engine({ adapter })
const canUpdate = await engine.can('user-1', 'update', { type: 'post', attributes: {} })
const canDelete = await engine.can('user-1', 'delete', { type: 'post', attributes: {} })
 
assert(canUpdate === true)
assert(canDelete === false)
import { Engine } from '@gentleduck/iam'
import { YourAdapter } from './your-adapter'
 
const adapter = new YourAdapter(/* config */)
 
// Seed
await adapter.saveRole({
  id: 'editor',
  name: 'Editor',
  permissions: [
    { action: 'read', resource: '*' },
    { action: 'update', resource: 'post' },
  ],
})
await adapter.assignRole('user-1', 'editor')
 
// Test
const engine = new Engine({ adapter })
const canUpdate = await engine.can('user-1', 'update', { type: 'post', attributes: {} })
const canDelete = await engine.can('user-1', 'delete', { type: 'post', attributes: {} })
 
assert(canUpdate === true)
assert(canDelete === false)

Test checklist

  • CRUD on policies (list, get, save w/ upsert, delete)
  • CRUD on roles
  • Idempotent assignRole (calling twice doesn't throw)
  • revokeRole without scope clears all scopes
  • revokeRole with scope only clears matching
  • getSubjectScopedRoles returns only scoped
  • setSubjectAttributes merges (doesn't replace)
  • Empty subject returns [] from getSubjectRoles, {} from getSubjectAttributes
  • End-to-end: engine.can() returns expected result after seeding via your adapter

The built-in adapters' test files (src/adapters/*/__tests__/*.test.ts) make a good copy-paste-and-adapt starting point.


Publishing your adapter

If your adapter is general-purpose, consider publishing it as a separate npm package (e.g. @your-org/duck-iam-adapter-foo). The duck-iam team welcomes upstream contributions for popular databases — open a PR with tests + docs.