Adapters overview
The storage layer for duck-iam - the IamAdapter.IAdapter contract method by method, the six built-in adapters, and how the engine caches on top of them
An adapter is the only thing IamEngine requires. It is a plain object implementing IamAdapter.IAdapter: nineteen async methods, thirteen required and six optional, that read and write policies, roles, role assignments, and subject attributes. Evaluation, caching, role inheritance, scope resolution, explain traces, and timeouts all live in the engine, so an adapter never decides anything - it only stores and returns rows.
The three stores
IamAdapter.IAdapter is the intersection of three narrower interfaces, all generic over the same TAction, TResource, TRole, and TScope parameters your engine is typed with. You can accept the narrower interface in your own code when you only need one part.
IPolicyStore and IRoleStore are symmetrical CRUD surfaces keyed on id. ISubjectStore is the asymmetric one: getSubjectRoles returns global assignments only, while getSubjectScopedRoles returns the scoped ones, and the two must never overlap.
Six members carry a ?. Five live on ISubjectStore; withClient is declared on IAdapter itself, because re-binding to a driver handle is a property of the whole adapter rather than of one store.
import type { AccessControl, IamAdapter, IamPrimitives, IamRequest } from '@gentleduck/iam'
export interface IAdapter<
TAction extends string = string,
TResource extends string = string,
TRole extends string = string,
TScope extends string = string,
> extends IPolicyStore<TAction, TResource, TRole>,
IRoleStore<TAction, TResource, TRole, TScope>,
ISubjectStore<TRole, TScope> {
withClient?(client: unknown): IAdapter<TAction, TResource, TRole, TScope>
}
Method reference
Every read method takes an optional IamAdapter.IReadOptions - currently just { signal?: AbortSignal }. Every method returns a promise.
| Method | Required | Returns | Contract |
|---|---|---|---|
listPolicies(opts?) | yes | IPolicy[] | Every stored policy, any order. Also the liveness probe behind engine.healthCheck(). |
getPolicy(id, opts?) | yes | IPolicy or null | null on a miss - never undefined, never a throw. |
savePolicy(policy) | yes | void | Upsert on policy.id. The engine invalidates its policy cache afterwards. |
deletePolicy(id) | yes | void | Idempotent; an unknown id resolves. |
listRoles(opts?) | yes | IRole[] | Every stored role. |
getRole(id, opts?) | yes | IRole or null | null on a miss. |
saveRole(role) | yes | void | Upsert on role.id. Invalidates the role cache. |
deleteRole(id) | yes | void | Idempotent, and it cascades: the role and every grant naming it go together. Leaving the grants is not the harmless option - they still read as grants, and a role later recreated under the reused id hands them back to everyone who once held it. |
getSubjectRoles(subjectId, opts?) | yes | TRole[] | Unscoped assignments only, deduplicated. [] for an unknown subject. |
getSubjectScopedRoles?(subjectId, opts?) | optional | IamRequest.IScopedRole[] | Scoped assignments only, one entry per (role, scope) pair. Omit it and every subject looks like it has no scoped roles. |
assignRole(subjectId, roleId, scope?, opts?) | yes | void | Idempotent on (role, scope). Refuses a role that is not stored, refuses scope: '' and scope: '*', and refuses any IAssignOptions field it cannot store. |
revokeRole(subjectId, roleId, scope?) | yes | void | With a scope: that scoped grant only. Without: every grant of that role, scoped and unscoped. |
updateAssignmentScope?(subjectId, roleId, fromScope, toScope, actor?) | optional | boolean | Move one grant in a single write; false when nothing matched fromScope - and false must mean nothing was written. |
assignRoleMany?(rows) | optional | number[] or null | Set-based assign. Returns indices into rows of the grants actually written, or null when the driver cannot say which were new. |
revokeRoleMany?(rows) | optional | number[] or null | Set-based revoke, same reporting rule. |
getSubjectGrantBoundary?(subjectId, opts?) | optional | number or null | Earliest future startsAt / expiresAt across the subject's grants, epoch ms, so the engine can cap its cache entry there. null when nothing is time-boxed. |
withClient?(client) | optional | IAdapter | Re-bind to a driver handle, typically a transaction. Returns a new adapter; the original keeps writing to its own client. |
getSubjectAttributes(subjectId, opts?) | yes | IamPrimitives.Attributes | {} for an unknown subject. Throw on a corrupt stored blob rather than returning {}. |
setSubjectAttributes(subjectId, attrs) | yes | void | Merge, never replace. Keys absent from attrs survive. |
Optional methods and their fallbacks
Omitting an optional method costs correctness nowhere - each one is either an optimisation the engine falls back for, or a capability the engine refuses outright rather than approximating:
getSubjectScopedRolesabsent - the engine has no scoped grants to merge. There is no error and no warning, so scope-aware evaluation degrades silently. All six shipped adapters implement it.updateAssignmentScopeabsent, or present and returningfalse-engine.admin.updateAssignmentScope(...)falls back torevokeRole+assignRole. Same end state, but you lose row identity (id,createdAt) and atomicity, and there is a window where the subject holds neither.assignRoleMany/revokeRoleManyabsent - the engine loops per row instead of issuing one statement.getSubjectGrantBoundaryabsent - the engine caches the subject for the fullcacheTTLinstead of capping the entry at the next grant transition.withClientabsent -engine.withTransaction(tx)throws, rather than silently writing outside the caller's transaction.
Which adapter implements what is declared once, in src/adapters/__compliance__/optional-support.ts, and checked against the real prototypes by optional-method-matrix.test.ts - so a method that disappears turns a row red instead of turning asserting tests into skipped ones. Choosing an adapter reproduces the table.
Thrown errors
The engine treats any rejection from an adapter as a hard failure and routes it through its fail-closed path (onError, then the configured defaultEffect). These throws are expected, and the last two come from the engine rather than from the adapter:
| Situation | Who throws | Message shape |
|---|---|---|
| Backend unreachable, permission denied, disk error | the adapter | whatever your driver throws, ideally wrapped with [@gentleduck/iam:<adapter>] |
attrs is not a plain object in setSubjectAttributes | the adapter, before writing | [@gentleduck/iam:<adapter>] attributes for "<id>" must be a plain object (got <type>) |
| A stored attributes blob is corrupt | the adapter, on read | [@gentleduck/iam:file] corrupted attributes for "<id>" (not a JSON object) and equivalents on every other backend |
| A stored policy row will not parse | the adapter, on read, after reporting it | policy "<id>" cannot be read and will not be skipped - a dropped policy may be the one that denies |
assignRole names a role the store does not hold | the adapter, before writing | [@gentleduck/iam:<adapter>] cannot assign a role that is not stored; save the role before granting it |
assignRole is given an option the adapter cannot store | the adapter, before writing | assignRole options (expiresAt) are not supported by this adapter, and were previously discarded silently |
engine.withTransaction(tx) on an adapter with no withClient | the engine | refuses rather than silently writing outside your transaction |
The read exceeded adapterTimeoutMs | the engine, not the adapter | [@gentleduck/iam:engine] <label> timed out after <N>ms |
More than maxPolicies / maxRoles rows came back | the engine, when filling the cache | a cap error; raise the cap rather than paginating inside the adapter |
The shape guard on setSubjectAttributes is shared: every built-in adapter calls the same iamAssertAttributesParam helper, so a string like 'admin=true' cannot spread into per-character attribute keys. A rejected call leaves existing attributes untouched.
{} for a corrupt attributes row silently strips ABAC from every decision about that subject, which usually widens access. Every built-in adapter throws instead. Do the same in a custom adapter.Concurrency expectations
The engine calls adapters concurrently and expects them to cope; it does not serialise writes for you.
- Reads are collapsed, not serialised. The engine keeps an in-flight map, so 500 simultaneous cold requests for one subject produce one
getSubjectRoles, not 500.maxConcurrentSubjectLoads(default512;0restores unbounded) caps how many distinct subjects may be loading at once; past the cap a new load rejects with a message containingsubject load shed. - Writes are not transactional across methods by default.
engine.admincalls one adapter method per operation. An adapter implementingwithClientcan join a caller's transaction throughengine.withTransaction(tx); drizzle and prisma do, and the other four throw there rather than writing outside it. setSubjectAttributesis a read-modify-write in every built-in adapter, and it is not wrapped in a transaction. Two concurrent merges to the same subject can lose one of them. Use an atomic merge in your backend if that matters (jsonb ||, a Mongo$set, a DynamoDBUpdateExpression).- Cancellation is best-effort. Adapters that can honour
opts.signalshould; adapters that cannot (memory, file) may ignore it. The engine releases the request either way whenadapterTimeoutMs(default5000,0disables) elapses - the orphaned work just runs to completion in the background.
Built-in adapters
| Adapter | Subpath | Use case | Persistence | Peer dependency |
|---|---|---|---|---|
| Memory | @gentleduck/iam/adapters/memory | tests, prototypes | none, in-process | none |
| File | @gentleduck/iam/adapters/file | CLIs, dev fixtures, single-process apps | one JSON file | none, you pass the fs driver |
| Prisma | @gentleduck/iam/adapters/prisma | production on Prisma | any Prisma-supported database | @prisma/client |
| Drizzle | @gentleduck/iam/adapters/drizzle | production on Drizzle | Postgres, MySQL, SQLite | drizzle-orm |
| Redis | @gentleduck/iam/adapters/redis | distributed deployments | Redis hashes and sets | ioredis or redis |
| HTTP | @gentleduck/iam/adapters/http | a central authorization service | remote, whatever it fronts | none, uses fetch |
| Custom | your package | anything else | your choice | your choice |
Every one of them also exports a lowercase factory (iamMemoryAdapter, iamFileAdapter, iamPrismaAdapter, iamDrizzleAdapter, iamRedisAdapter, iamHttpAdapter) for callers who prefer functions to new. The factories take the same arguments as the constructors.
Where an adapter sits
Adapters are behind two cache layers, which is why a warm process barely touches storage.
Both the subject LRU (maxCacheSize, default 1000 entries) and the policy/role caches expire after cacheTTL seconds (default 60; 0 disables caching entirely and makes every call hit the adapter). Writes through engine.admin invalidate the matching cache immediately, so an adapter never has to publish anything itself - unless you run more than one process, in which case wire the Redis invalidator.
Swapping adapters
Adapters are interchangeable by construction: engine, builder, server middleware, and client code do not know which one is underneath. Migration is data movement only.
import { IamEngine } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
// development
const adapter = new IamMemoryAdapter({ roles, policies, assignments: { 'user-1': ['admin'] } })
const engine = new IamEngine({ adapter, defaultEffect: 'deny' })
const allowed = await engine.can('user-1', 'read', { type: 'post', attributes: {} })
Swapping IamMemoryAdapter for IamDrizzleAdapter changes those two lines and nothing else. Choosing an adapter walks the trade-offs.
Verifying an adapter
Every shipped adapter runs the same vitest matrix, runAdapterCompliance(name, factory, { supports }), which pins the cross-backend contract: empty stores return [] and null, saves upsert, deletes are idempotent, deleteRole cascades, getSubjectRoles returns only unscoped roles, getSubjectScopedRoles returns only scoped ones, revoke with a scope removes just that grant while revoke without one removes them all, and setSubjectAttributes merges rather than replaces. supports declares which optional methods you implement; clauses for the rest are never registered, so an unimplemented method cannot show up as a pass. A second suite, runEngineCapabilityCompliance, runs the same behaviour one rung up through engine.admin, where the fallbacks live. See Custom adapter.
See also
- Choosing an adapter - feature matrix, decision tree, FAQ
- Custom adapter - implement the interface for any backend
- Caching - what the engine keeps in front of your adapter
- Admin API - the write path that calls these methods