Skip to main content

Redis adapter

Distributed policy, role, and assignment storage in Redis hashes and sets, with the exact key layout, encoding, and consistency guarantees.

IamRedisAdapter keeps the whole store in four kinds of Redis key: two hashes for policies and roles, one set per subject for assignments, one string per subject for attributes. It needs twelve commands from your client, all of which ioredis, node-redis v4+, and Upstash implement directly, so there is no hard dependency on any Redis library.

Install


npm i @gentleduck/iam ioredis

ioredis and redis are both optional peer dependencies; install whichever you use.

import { IamRedisAdapter, iamRedisAdapter } from '@gentleduck/iam/adapters/redis'
import type { IamRedis } from '@gentleduck/iam/adapters/redis'

Setup

ioredis
import Redis from 'ioredis'
import { IamEngine } from '@gentleduck/iam'
import { IamRedisAdapter } from '@gentleduck/iam/adapters/redis'

const adapter = new IamRedisAdapter({
  client: new Redis(process.env.REDIS_URL!),
  keyPrefix: 'iam:',
  onPolicyError: (err, ctx) => logger.error({ err, ...ctx }, 'iam row dropped'),
})

const engine = new IamEngine({ adapter })
node-redis v4+
import { createClient } from 'redis'
import { IamRedisAdapter } from '@gentleduck/iam/adapters/redis'

const client = createClient({ url: process.env.REDIS_URL })
await client.connect()

const adapter = new IamRedisAdapter({ client, keyPrefix: 'iam:' })
Upstash (REST)
import { Redis } from '@upstash/redis'
import { IamRedisAdapter } from '@gentleduck/iam/adapters/redis'

const adapter = new IamRedisAdapter({ client: Redis.fromEnv(), keyPrefix: 'iam:' })

iamRedisAdapter(config) is the same constructor behind a factory function.

Options

IamRedis.IConfig<TClient> is the single constructor argument.

OptionTypeDefaultMeaning
clientTClient extends IamRedis.ILikerequiredAny client exposing the twelve commands below. Not typed against ioredis or node-redis specifically, so a shim or a test double works.
keyPrefixstring''Prepended verbatim to every key. Include your own separator 'iam:' gives iam:policies, 'iam' gives iampolicies. Glob metacharacters are escaped where the prefix meets a pattern; see deleteRole and the sweep.
onPolicyError(err, ctx: { adapter: 'redis'; rowId: string }) => voidconsole.warnCalled for every row dropped on a parse or shape failure, for a corrupt attribute blob, for a deleteRole that could not sweep, and for a failed legacy migration.
migrateLegacyAssignmentsbooleanfalseRewrite space-separated assignment members into the NUL encoding on read. Off by default and should usually stay off - see Legacy assignment encoding.

The client interface

export namespace IamRedis {
  export interface ILike {
    get(key: string): Promise<string | null>
    set(key: string, value: string): Promise<unknown>
    del(...keys: string[]): Promise<number>
    hset(key: string, field: string, value: string): Promise<number>
    hget(key: string, field: string): Promise<string | null>
    hdel(key: string, ...fields: string[]): Promise<number>
    hkeys(key: string): Promise<string[]>
    hvals(key: string): Promise<string[]>
    hgetall(key: string): Promise<Record<string, string>>
    sadd(key: string, ...members: string[]): Promise<number>
    srem(key: string, ...members: string[]): Promise<number>
    smembers(key: string): Promise<string[]>
    /** Optional Lua EVAL for a cross-process atomic legacy migration; ioredis positional shape. */
    eval?(script: string, numkeys: number, ...keysAndArgs: string[]): Promise<unknown>
    /** Optional. Without it `deleteRole` cannot enumerate the assignment sets and does not cascade. */
    keys?(pattern: string): Promise<string[]>
  }
}

Twelve required methods and two optional. eval buys cross-process atomicity for the legacy migration; keys is what makes deleteRole cascade. Both ioredis and node-redis v4+ expose both. SCAN is not used in place of keys because its cursor and options differ between the two clients, while keys(pattern) is spelled identically on both.

Key layout

Four key shapes. ${p} is keyPrefix.

KeyTypeField / memberValue
${p}policiesHashpolicy idJSON.stringify(policy) the whole AccessControl.IPolicy
${p}rolesHashrole idJSON.stringify(role) the whole AccessControl.IRole
${p}assignments:${subjectId}SetroleId + 0x00 + scope (scope is the empty string when unscoped)n/a, the member is the data
${p}attrs:${subjectId}Stringn/aJSON.stringify(attributes)

Loading diagram...

The two global keys grow with the size of your policy and role set; the per-subject keys grow with your user count, two keys each. Nothing is ever expired by the adapter — no EXPIRE is issued on any key — so persistence is entirely your Redis configuration's problem. Turn on AOF or RDB snapshots if you do not want the store to vanish on restart.

listPolicies is a single HGETALL on ${p}policies, so the whole policy set crosses the wire on every cold read. That is fine at the scale the engine caps you at (maxPolicies / maxRoles, both default 10_000) and is the reason to pair this adapter with the engine's in-process LRU rather than reading through to Redis on every check.

Assignment member encoding

A set member packs the role and the scope into one string separated by a NUL byte (0x00):

editor\x00           -> { role: 'editor' }                    (unscoped, empty scope tail)
admin\x00org-1       -> { role: 'admin', scope: 'org-1' }
team lead\x00us west -> { role: 'team lead', scope: 'us west' }

Set semantics do the deduplication, which is what makes assignRole idempotent: adding the same (role, scope) twice is one SADD that returns 0 the second time.

deleteRole and the sweep

deleteRole removes the role from the hash and then removes every grant naming it, because a role that outlives its own grants is a role that comes back: recreate it under the reused id and everyone who once held it holds it again, with nobody granting anything.

There is no foreign key to do this, so the adapter runs KEYS ${prefix}assignments:*, reads each set's members, and SREMs the ones whose decoded role matches — each key's rewrite serialised so a concurrent assignRole on that subject cannot land between the read and the removal. A whole-keyspace sweep is acceptable because deleting a role is an admin-rate operation and never on a request path. A reverse index was rejected as the alternative: it would cascade only for grants written after the index existed and silently miss every older one.

The prefix is escaped before it reaches that pattern — globLiteral backslash-escapes \ * ? [ ], the metacharacters KEYS understands. A prefix of app[1]: interpolated raw turns [1] into a character class, so the sweep misses every key it owns and matches app1:assignments:*, a different tenant's namespace, where it removes that tenant's grants of the same role id. Two failures at once, neither of which raises anything.

Escaping cannot help with a literal overlap, though. With keyPrefix: 'iam:' the sweep pattern is iam:assignments:*, whose trailing * matches any suffix, so a second adapter configured with keyPrefix: 'iam:assignments:' has every one of its keys inside the first adapter's sweep. Do not nest one prefix inside another.

Legacy assignment encoding

Earlier versions used a literal space as the separator, which silently broke any role or scope containing a space: 'admin user' round-tripped as ('admin', 'user '). Members are now NUL-separated. Both reads decode a legacy member correctly whatever you configure; rewriting it is opt-in through migrateLegacyAssignments, which defaults to off.

With it on, the migration touches only members that look legacy, re-checks under the lock so a concurrent writer that already migrated the set means no write at all, and never issues a zero-member SADD / SREM, which real Redis answers with an error rather than a no-op.

Loading diagram...

Both getSubjectRoles and getSubjectScopedRoles decode legacy members before returning, then kick off the migration if it is enabled, so a read never blocks on it and never returns stale data. When the client exposes eval, the migration is one Lua script that SADDs the new form and SREMs the old one per member — atomic and safe across processes. Without eval, the adapter falls back to an in-process promise chain keyed on the assignments key, which serialises the migration against assignRole and revokeRole in this process only.

The in-process lock is what keeps every read-modify-write against one subject from interleaving, migration or not. It is deleted only when the stored promise is still the one that settled; inverting that identity check drops a concurrent writer's lock and lets a later write run straight into an in-flight one, which 33 tests did not notice.

revokeRole with a scope defends against a partially migrated set by SREMing both encodings in one call — the NUL form and the legacy "<role> <scope>" form.

Transactions and consistency

Redis commands are individually atomic; the adapter groups nothing into MULTI.

OperationCommandsAtomic?
savePolicy / saveRoleone HSETyes
deletePolicyone HDELyes
getPolicy / getRoleone HGETyes
listPolicies / listRolesone HGETALLyes, a point-in-time snapshot
assignRoleone SADDyes, and idempotent
revokeRole with a scopeone SREM covering both encodingsyes
revokeRole without a scopeSMEMBERS, filter, then SREM, serialised per keyin this process only a writer in another process can land between the two
deleteRoleHDEL, then KEYS + per-key SMEMBERS/SREMno the role goes first, then the sweep
getSubjectAttributesone GETyes
setSubjectAttributesGET, merge in JavaScript, SETno read-modify-write

Connection failures surface on every read and on every write except setSubjectAttributes: they reject with the driver's error rather than degrading to [], null or a reported success.

Scope

Scope lives inside the set member, after the NUL. An empty tail is a global grant; anything else is a scoped one.

getSubjectRoles and getSubjectScopedRoles both SMEMBERS the same key and split the decoded results:

await adapter.assignRole('alice', 'admin', 'org-1')
await adapter.assignRole('alice', 'viewer')

await adapter.getSubjectRoles('alice')        // ['viewer']
await adapter.getSubjectScopedRoles('alice')  // [{ role: 'admin', scope: 'org-1' }]

revokeRole without a scope removes every member whose decoded role matches, at any scope. With a scope it removes only that pair.

updateAssignmentScope is not implemented here. Scope is encoded into the member itself, so there is no cheaper "in place" path than remove plus add — exactly what the engine's fallback already does. engine.admin.updateAssignmentScope therefore issues revokeRole + assignRole against this adapter and succeeds normally. Since 5.5.0.

IamAdapter.IAssignOptions (startsAt / expiresAt / per-grant attributes) is likewise not implemented, and assignRole throws on those options rather than accepting the grant and dropping them — a break-glass grant issued with a one-hour expiry against an adapter that discards it is permanent. Grants in Redis never expire on their own and scoped roles from this adapter carry no attributes. Use the Drizzle adapter if you need either.

assignRole also refuses a role the hash does not hold, and refuses scope: '' and scope: '*' on a grant. The empty string is exactly how this encoding spells "no scope", so an empty scope would decode as a global grant; '*' is matched literally, so a grant stored there would answer only a request whose own scope is the string "*". Both are accepted on a lookup, so an operator can still delete rows written before the guards existed.

Multi-tenancy

keyPrefix is how you put more than one tenant on one Redis instance:

const tenant1 = new IamRedisAdapter({ client, keyPrefix: 'iam:tenant1:' })
const tenant2 = new IamRedisAdapter({ client, keyPrefix: 'iam:tenant2:' })

await tenant1.savePolicy({ id: 'p1', /* … */ })
await tenant2.getPolicy('p1') // null

Row validation

listPolicies, getPolicy, listRoles, and getRole parse each stored blob and then run it through parsePolicyRow / parseRoleRow from @gentleduck/iam/core/validate. Every failure is reported through onPolicyError with { adapter: 'redis', rowId } and the joined validator issues — but what happens next depends on which table it came from.

A bad role row is dropped and the rest of the hash is returned, because role permissions are allow-only: losing one can only cost a subject a grant. A bad policy row makes the read throw, and the engine denies until it is repaired, because a dropped policy may be the one that says NO. getPolicy on a corrupt row throws rather than returning null: null is the answer for a row that is not there, and a corrupt row must not be able to impersonate a deleted one.

A __proto__ key inside a stored bag is refused rather than read past, and a row stored under the id __proto__ is returned rather than dropped — verified against a real server, because that is where a client's own out[field] = value materialisation would invoke the inherited setter.

Subject attributes are the deliberate exception.

setSubjectAttributes is the one path that tolerates a failing read: it catches, reports through onPolicyError, and merges onto {} so an operator can always overwrite a broken key. The catch is not narrowed to the corrupt-blob case, though. When the GET fails transiently and the SET then succeeds, the stored bag is replaced by the patch alone and every key the patch does not name is gone. Pass an onPolicyError and treat a report from here as a write that may have truncated the bag, not as a logged curiosity.

It also guards its own argument — a non-object attrs throws [@gentleduck/iam:redis] attributes for "<id>" must be a plain object (got string) rather than spreading a string into per-character keys.

Pairing with the engine cache

Redis costs a network hop per cache miss, so run it behind the engine's in-process LRU rather than in front of every check:

const engine = new IamEngine({
  adapter: new IamRedisAdapter({ client, keyPrefix: 'iam:' }),
  cacheTTL: 60,
  maxCacheSize: 10_000,
})

Hot reads never leave the process. After the TTL expires, or after an explicit invalidation, the next read hits Redis. In a multi-node deploy the piece you still need is a way to tell the other nodes that a policy changed — that is the Redis invalidator, which is a separate export from this adapter and can run on the same connection.

When to use

  • Multi-instance deploys where every node must see the same policy set without a database round trip per check.
  • Edge and serverless runtimes: Upstash over REST, or any HTTP-shaped client wrapped to match IamRedis.ILike.
  • You already run Redis. If you do not, a SQL adapter is fewer moving parts than a new stateful dependency.

Reach for something else when the store is large — everything here is resident in RAM, and a six-figure policy set with deep rule trees is a real memory cost that a relational adapter with indexes handles better — or when a single instance is enough, where IamMemoryAdapter is faster and free.

See also