Skip to main content

Choosing an adapter

Honest feature matrix across the six built-in adapters, a decision tree, and answers to the questions that come up when picking or migrating storage

All six built-in adapters pass the same compliance suite, so they agree on every decision your engine makes. What differs is durability, concurrency, which optional methods they implement, and how loudly they fail.

Feature matrix

MemoryFilePrismaDrizzleRedisHTTP
Persistencenoneone JSON fileany Prisma databasePG / MySQL / SQLiteRedis, no TTLremote service
Peer dependencynonenone, you pass fs@prisma/clientdrizzle-ormioredis or redisnone, uses fetch
Survives restartnoyesyesyeswith AOF/RDByes
Safe with many writersn/anoyesyesyesdepends on the service
Idempotent assignRoleyes, in-memory checkyes, in-memory checkyes, read-then-write, P2002 is successyes, onConflictDoNothingyes, SADD set semanticsdepends on the service
onPolicyError hooknoyesno, console.warnyesyesyes
Row validation on readyesyesyesyesyesyes
Temporal grants (IAssignOptions)refusedrefusedrefusedyes, starts_at / expires_atrefusedrefused
Per-grant attributes on IScopedRolenononoyesnono
Honours opts.signalnononononoyes, via fetch
Write cost per mutationone Map.setfull-document rewriteone rowone rowone commandone request

Optional methods

This table is src/adapters/__compliance__/optional-support.ts, which is declared rather than probed and checked against the real prototypes by optional-method-matrix.test.ts.

Optional methodMemoryFilePrismaDrizzleRedisHTTP
getSubjectScopedRolesyesyesyesyesyesyes
updateAssignmentScopeyesyesyesyesnono
getSubjectGrantBoundarynononoyesnono
assignRoleManynononoyesnono
revokeRoleManynononoyesnono
withClientnonoyesyesnono

Three of the six exist on Drizzle alone, because only its schema carries starts_at / expires_at and only it can batch through a transaction.

Notes on the sharp bits:

  • A missing optional method costs correctness nowhere. updateAssignmentScope, assignRoleMany and revokeRoleMany are optimisations the engine falls back for - revoke-then-assign, and a per-row loop - and runEngineCapabilityCompliance pins the behaviour behind them on all six adapters regardless of what the table says. The exception is withClient: without it engine.withTransaction throws rather than writing outside your transaction, so only Drizzle and Prisma can join one.
  • IAssignOptions is refused, not dropped. Only Drizzle stores startsAt / expiresAt / attributes. The other five throw and name the option rather than accepting the grant and discarding the expiry, which used to make a break-glass grant permanent while the batch API still reported applied: 1.
  • Memory and Prisma have no onPolicyError hook. Memory validates its seed through the same guards its write path uses; Prisma has no options object to wire a handler into, so its dropped-row reports go to console.warn.

Decision tree

Loading diagram...

The first branch that matters is writers, not readers. Many read-only processes are fine against a file, because the engine's LRU absorbs almost all traffic; two processes that both call engine.admin.* against one file will clobber each other. The second is whether you already run an ORM: if you do, the matching adapter is almost always the right answer, because your migrations, backups, and observability already cover that database. Redis earns its place when several instances need the same authorization state with sub-millisecond reads; a custom adapter is the answer when your data already lives somewhere none of the six understand.

Migrating between adapters

Engine, builder, server middleware, and client code are unchanged - only the constructor line and the data move.

Export from the old adapter

Read everything through the adapter interface itself: listPolicies(), listRoles(), and, per subject, getSubjectRoles(), getSubjectScopedRoles(), and getSubjectAttributes(). There is no listSubjects on the interface, so you need your own list of subject ids - usually a query against your users table.

Import into the new adapter

Replay the same calls as writes: savePolicy, saveRole, one assignRole(subjectId, roleId, scope?) per grant, and setSubjectAttributes. Save the roles before the grants - every adapter refuses an assignment naming a role it does not hold. Saves are upserts and assigns are idempotent everywhere, so the import can be re-run safely.

Verify with the same decisions

Run your authorization tests against both adapters and assert identical results. Adapters that disagree are usually adapters where one broke the scoped/unscoped split.

Swap the constructor and warm the cache

Change the adapter passed to IamEngine, then call await engine.preload() on boot so the first request does not pay the cold read.

FAQ

See also