Skip to main content

redis invalidator

Cross-instance cache invalidation broadcaster backed by Redis pub/sub.

What it does

createIamRedisInvalidator wires a duck-iam engine to a Redis pub/sub channel so every node in a fleet drops its local caches when any node mutates a policy, role, or subject. Without it, engine.admin.savePolicy() on pod A leaves pods B - J serving stale grants for up to cacheTTL seconds. With it, mutations propagate within one round-trip.


Install

The invalidator has zero runtime dependencies. Pair it with your existing Redis client (ioredis or node-redis v4+).

import { createIamRedisInvalidator } from '@gentleduck/iam/invalidators/redis'

Usage

Pass it into IConfig.invalidator:

import { IamEngine } from '@gentleduck/iam'
import { createIamRedisInvalidator } from '@gentleduck/iam/invalidators/redis'
import Redis from 'ioredis'

const pub = new Redis()
const sub = new Redis() // subscriber must be a separate connection

const invalidator = createIamRedisInvalidator({
  client: {
    publish: (ch, msg) => pub.publish(ch, msg),
    subscribe: (ch, handler) => {
      sub.subscribe(ch)
      sub.on('message', (chan, message) => {
        if (chan === ch) handler(message)
      })
    },
    unsubscribe: (ch) => sub.unsubscribe(ch),
  },
  channel: 'iam:invalidate', // optional; defaults to 'duck-iam:invalidate'
  secret: process.env.IAM_INVALIDATE_SECRET, // SEC-046 - required in prod
})

const engine = new IamEngine({ adapter, invalidator })

process.on('SIGTERM', () => engine.dispose())

How it works

Every local engine.admin.* mutation publishes a discriminated-union event:

type IInvalidateEvent =
  | { kind: 'all' }
  | { kind: 'policies' }
  | { kind: 'roles'; roleId?: string }
  | { kind: 'subject'; subjectId: string }

Every engine subscribed to the same channel receives the event and applies the matching local invalidate (invalidate*({ broadcast: false })) so it doesn't re-publish - no echo storm.

Each engine instance embeds a per-process UUID in every published event. The subscriber filters out messages with its own UUID, so a node never re-applies its own mutation.


Multi-tenant channels

In multi-tenant deployments where different tenants must not cross-invalidate, pass tenantId and the invalidator auto-prefixes the channel as duck-iam:invalidate:tenant:${tenantId} (or ${channel}:tenant:${tenantId} when channel is also given). CAVEAT-1.

createIamRedisInvalidator({
  client,
  secret: process.env.IAM_INVALIDATE_SECRET,
  tenantId: req.tenantSlug, // validated against /^[A-Za-z0-9_-]{1,64}$/
})

tenantId is validated against /^[A-Za-z0-9_-]{1,64}$/ so an attacker-controlled tenant slug cannot inject pub/sub wildcards (*) or whitespace that would confuse downstream subscribers. Invalid slugs throw at construction.

Building the channel name yourself with channel: '...' still works for existing deployments, but tenantId is the recommended path for new code.


Publish failures

engine.admin.* mutations call publish() synchronously; a thrown error is non-fatal for the local engine (it already applied the invalidation) but remote nodes will not receive the broadcast. Wire onPublishError to your alerting pipeline so a long-lived Redis outage does not silently desync caches across nodes. SEC-062.

createIamRedisInvalidator({
  client,
  secret: process.env.IAM_INVALIDATE_SECRET,
  onPublishError: (err, channel) => {
    metrics.increment('iam.invalidator.publish_failed', { channel })
    sentry.captureException(err, { extra: { channel } })
  },
})

Without the hook, the invalidator falls back to a rate-limited console.warn so the failure is not totally silent.


Delivery semantics

At-least-once. engine.cache.invalidate*() methods are idempotent - applying the same event twice is safe. If the Redis client drops a message, that node's cache stays stale for at most cacheTTL seconds; the next request after expiry will re-fetch from the adapter.

If publish throws synchronously, the local invalidate already applied - the failure is non-fatal. Remote nodes will pick up the change on TTL.


Custom transports

If Redis isn't your transport, implement IamEngineTypes.IInvalidator directly:

import type { IamEngineTypes } from '@gentleduck/iam/core'

const invalidator: IamEngineTypes.IInvalidator = {
  publish(event) {
    nats.publish('iam.invalidate', JSON.stringify(event))
  },
  subscribe(handler) {
    const sub = nats.subscribe('iam.invalidate', (msg) =>
      handler(JSON.parse(msg.data)),
    )
    return () => sub.unsubscribe()
  },
}

Works with NATS, Kafka, RabbitMQ, MQTT, or any in-process bus. The contract is the same: publish is synchronous (or returns a Promise; engine doesn't await), subscribe returns a teardown function.


Constructor config

OptionTypeDefaultDescription
clientIamRedisInvalidator.IPubSubLike--Pub/sub adapter with publish + subscribe (+ optional unsubscribe)
channelstring'duck-iam:invalidate'Pub/sub channel name
tenantIdstring--Auto-prefixes the channel as ${channel}:tenant:${tenantId}. Validated against /^[A-Za-z0-9_-]{1,64}$/. CAVEAT-1.
secretstring | nullnullHMAC-SHA256 shared secret. Required in production - without it any party with PUBLISH rights on the channel can wipe caches.
onPublishError(err, channel) -> voidconsole.warn (rate-limited)Wire to alerting; non-fatal but cross-instance invalidations are lost. SEC-062.

The IamRedisInvalidator.IPubSubLike shape is intentionally minimal - both ioredis and node-redis v4+ satisfy it directly. The full options object conforms to IamRedisInvalidator.IConfig.


Types

All types live under the RedisInvalidator namespace at @gentleduck/iam/invalidators/redis. Type-only - zero bundle cost.

  • IamRedisInvalidator.IPubSubLike - minimal pub/sub client surface (publish, subscribe, optional unsubscribe).
  • IamRedisInvalidator.IConfig - constructor config for createIamRedisInvalidator (client, optional channel).
import type { IamRedisInvalidator } from '@gentleduck/iam/invalidators/redis'

const config: IamRedisInvalidator.IConfig = {
  client: pubSubClient,
  channel: 'iam:invalidate',
}

Deprecated bare aliases (IRedisPubSubLike, IRedisInvalidatorConfig) remain for back-compat and will be removed in 3.0.