← back/blog/multi-tenant-one-redis

the Room Protocol · multi-tenancy

Multi-tenancy on one Redis, no auth server

The other posts cover how the protocol works for one team. This is the part that keeps your rooms from leaking into someone else's — and does it without standing up an auth service or a single database table.

11 min read·shreyaspadmakiran.com

A coordination server that any bearer can read is a demo. The moment two teams point their agents at the same server, roomd has to guarantee that team A can never see team B's plans, contracts, or events — and it has to do that while staying a stateless server on one Redis, with no relational database, no identity provider, and no session store. This post is how that isolation actually works, and why each piece is the shape it is.

Three things have to be true for real multi-tenancy, and they get conflated constantly: isolation (A can't read B's data), attribution (the server knows which team every request belongs to, so it can meter and eventually bill), and scoped access (you can hand a collaborator into one room without handing them everything). roomd gets all three from a single move: collapse every credential down to one team identity, then key everything off it.

Act I — One identity, three doors

Every request resolves to a teamId

Before any tool runs, roomd turns the request's bearer secret into a teamId— the one identity that owns rooms and data. There are three kinds of secret, checked cheapest-first, but they all answer the same question (“which team is this?”):

async function resolveKey(secret): Promise<KeyContext | null> {
  // 1. static env keys — in-memory map, no I/O
  const staticTeam = keyMap.get(secret);
  if (staticTeam) return { teamId: staticTeam, isStatic: true,  isInvite: false };

  // 2. dynamic keys — created at runtime, stored in Redis
  const dyn = await getDynamicKey(secret);
  if (dyn)        return { teamId: dyn.teamId,  isStatic: false, isInvite: false };

  // 3. room-scoped invite tokens — access to exactly one room
  const invite = await getInviteToken(secret);
  if (invite)     return { teamId: invite.createdBy,
                           allowedRoomId: invite.roomId,
                           isInvite: true, isStatic: false };

  return null; // unrecognised → 401
}

The ordering is deliberate: the common case (a known team key) is a hash-map hit with zero network round trips, and only unrecognised secrets pay for a Redis lookup. But the point is the return shape — every branch produces a teamId. An invite additionally carries an allowedRoomId and a flag that pins it to one room. That is the entire identity model.

Why three bearer keys instead of an auth service

The reflex for “who is this and what can they do” is an auth service: OAuth, JWTs, a users table, refresh tokens, sessions. That's the right tool when you have human users with profiles, passwords, and roles. roomd's callers are agents and the teams that run them, and what they need is far narrower: a durable secret that maps to a team. A bearer secret resolving to a teamId gives exactly that in a single lookup — no login flow, no token-expiry choreography, no identity provider to operate.

The three types aren't three auth systems; they're three ways to mint the same teamId for three situations. Static env keys are the ones the operator sets by hand. Dynamic keys are created at runtime for self-serve. Invite tokens are scoped to a single room for guests. Because everything downstream sees only the teamId, the auth surface and the coordination logic are decoupled by exactly one string — I can add, remove, or change how people authenticate without touching a line of the tool handlers.

Three secret types resolving to one team identityA static key, a dynamic key, and an invite token on the left all resolve through resolveKey into a single teamId, which gates access to rooms.static keyoperator-setdynamic keyself-serveinvite tokenone room onlyresolveKey()→ teamIdrooms ownedby this team(invite → 1 room)
Three ways in, one identity out. Ownership, metering, and access all read the same teamId, so the rest of the system never learns how you authenticated.
Identity answers “which team.” It doesn't yet answer “which rooms are yours” — and the usual answer to that is a database table roomd deliberately doesn't have.
Act II — Ownership without a table

First touch wins

A team needs to own its rooms so no one else can reach them. The textbook approach is a rooms table, a memberships table, and the CRUD to manage both. roomd has neither. A room is owned by the first team that touches it, claimed with the same atomic primitive the plan uses for locking:

// runs on every tool call, before the tool does anything
const claimed = await redis.set(ownerKey, teamId, { nx: true });
if (claimed === "OK") return;                 // first touch → you own it now
const owner = await redis.get(ownerKey);
if (owner !== teamId)
  throw new Error("Room not found or access denied");

nxmeans “set only if the key doesn't exist,” so the first caller on a given roomId wins ownership atomically. Every later call by the same team passes; any other team gets a deliberately vague error that doesn't even confirm the room exists. Invite tokens skip the claim entirely, because their scope is already fixed to one room by the allowedRoomId from resolveKey.

Why first-touch over a membership table

A rooms-and-members schema would be more “correct” in a CRUD sense, so it's worth being concrete about what it buys and costs. It buys a room-creation endpoint, a join flow, membership rows, and the queries to check them on every single call — and it drags in a relational database to hold all that, which the single-store architecture exists specifically to avoid. First-touch ownership gets the one property that actually matters — a room is private to exactly one team — from a single SET NX, with no schema, no create step, and no second datastore. Rooms spring into existence the instant an agent names one, and they're private from that instant.

The tradeoff is real and worth naming out loud: there is no shared ownership and no transfer. A room belongs to one team, full stop. For coordinating one team's agents, that isn't a limitation — it's the point. If cross-team rooms ever become a real need, that's a membership table added deliberately for that feature, not schema you carried from day one on the chance you'd want it.

First-touch room ownership via SET NXteam-acme calls SET NX on the room owner key and wins ownership. team-globex calls the same and is refused because the owner is already team-acme.room:x:owner= team-acmeteam-acmeSET NX → OKowns itteam-globexSET NX → (exists)acme: allowedglobex: room notfound / denied
One atomic write decides ownership forever. The loser can't even tell whether the room exists — the error is the same as for a room that was never created.
Act III — Keeping it standing

Rate limiting that fails open

Each team gets a per-minute budget from a fixed-window counter that expires on its own:

const count = await redis.incr(rateKey);        // this minute's bucket
if (count === 1) await redis.expire(rateKey, 120);
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };

// and if Redis itself is unreachable:
catch { return { allowed: true, remaining: limit }; }  // FAIL OPEN

The decision hiding in that last line is fail-open versus fail-closed, and it's a real fork. A fail-closed limiter, the instant its own store stutters, freezes every team's agents mid-task — it converts a dependency blip into a total outage of the one thing the server exists to provide. For a coordination substrate agents lean on to make progress, a few seconds of unmetered traffic is a rounding error; a few seconds of everyone frozen is precisely the failure you were trying to prevent. So it fails open. A payments system would choose the exact opposite, and be right to — the lesson is that “fail open or closed” is a product question wearing an infrastructure costume, and you should answer it on purpose.

The counter is keyed by teamId, which is the same reason attribution came for free: the thing that meters you is the thing that isolates you.

The key that mints keys

Once keys can be created at runtime, an escalation opens up: could a team use its own key to mint keys for other teams, or spin up unlimited free identities? The guard is one flag from resolveKey. Provisioning a brand-new team is allowed only from a static env key — the ones only the operator controls — never from a dynamic key issued to a caller:

// POST /admin/keys/provision  (the dashboard calls this on signup)
if (!keyCtx.isStatic)
  return c.json({ error: "Only static env keys may provision new teams" }, 403);

So the operator, holding a static master key server-side, can hand every new signup its own isolated team, while a caller's own dynamic key can manage its team's rooms and keys all day but can never bootstrap a new tenant. One boolean draws the line between self-serve signup and privilege escalation, and it costs a single if.

What this version doesn't do

The honest edges. Ownership is single-team, so there's no shared room across tenants and no transferring a room to another team. The rate limiter is a fixed window, so a burst straddling two windows can briefly exceed the nominal limit — fine at current load, worth swapping for a sliding window before anyone treats it as a hard billing boundary. And identity lives behind the bearer secret, so rotating a leaked key means issuing a new one and revoking the old (dynamic keys support revoke; static keys are managed in the environment). None of these dents the isolation guarantee; they're the gap between “multi-tenant” and “multi-tenant with every enterprise knob.”

The thread running through all of it is that isolation here is naming discipline, not machinery. One string decides what a request can see, one atomic write decides who owns a room, one boolean decides who can mint identities. There's no auth service to secure and no schema to migrate because there is almost nothing there — and for a system one person keeps running, the best security surface is the one small enough to hold in your head.

— shreyas

more in this series

01Coordinating agents through shared state · the idea

02Concurrency control for a shared plan · locks and cursors

03A stateless server on a single Redis · architecture

04Typed context over prose and vector search · context model

05MCP as transport · protocol layer

06Multi-tenancy on one Redis · you are here