← All posts

Designing workspace-scoped multi-tenancy for a developer platform

How Rezee isolates workspaces on one Postgres - scoping by foreign key, revocable server-side sessions, hashed access tokens, and the single authorize endpoint that answers every git permission question.

May 12, 2026 · 5 min read · Kash Gohil

Everything in Rezee is workspace-scoped - repos, issues, docs, chat all live inside a workspace, and a workspace must never leak into another. This post is how that isolation actually works: one Postgres, foreign keys and discipline, revocable sessions, and a single endpoint that answers every "can this identity touch this repo?" question for the whole git surface. Plus the trade-offs we took knowingly.

How is tenant data isolated?

The unglamorous industry-standard way: one database, one schema, application-level scoping. Every tenant-owned table carries a workspace_id foreign key with ON DELETE CASCADE, every query filters by it, and uniqueness is per-workspace - two workspaces can each have a repo named web, because the unique index is on (workspace_id, name).

The alternatives we didn't take, and why: schema-per-tenant multiplies migration surface by tenant count for isolation we can enforce in queries; database-per-tenant is operational overkill below serious scale; Postgres row-level security is the interesting one - real defense-in-depth against a forgotten WHERE clause - and it's the upgrade path, not the starting point, because RLS debugging is its own tax while the data model is still moving fast. For now, isolation lives in the query layer and in review discipline. Written down honestly so the trade is visible.

Workspaces come in two kinds - every user gets a personal workspace at registration (slug = username), and teams create shared ones - with membership and roles (owner, admin, member) in a join table, plus per-repo collaborators (read/write) layered on top for finer grants.

How do sessions and tokens work?

Sessions are server-side rows, not JWTs. Login creates a session row; a random ID travels in an httpOnly, SameSite=Lax cookie. The deliberate part is what this buys: revocability. Deleting the row logs the session out, now - no waiting for a token to expire, no denylist infrastructure. Stateless tokens are a scaling optimization for a problem we don't have; "can we kill a session?" is a security requirement we do. Passwords are argon2id, and login verifies against a precomputed dummy hash when the username doesn't exist, so response timing doesn't reveal which usernames are real.

Personal access tokens - what git push over HTTPS uses - are generated once, shown once, and stored only as a SHA-256 hash; verification is a hash lookup, with optional expiry and last-used tracking. The honest limitation: PATs currently carry no scopes - a token acts as its owner, fully. Scoped tokens (per-repo, read-only) are the roadmap item we feel most acutely, precisely because of what agents need.

How does the git server decide permissions?

It doesn't - and that's the design. denji forwards every decision to one internal endpoint, /internal/authorize, which resolves the identity (a PAT from HTTP Basic auth, or a pre-resolved user from an SSH key fingerprint), joins the repo with the caller's workspace role and collaborator grant, and computes an effective role, strongest-wins:

const isOwner = userId !== null && repo.ownerId === userId;
const wsRole = repo.workspaceRole; // set only when the caller is a member
let role: "write" | "read" | null = null;
if (isOwner || repo.collaboratorRole === "write") role = "write";
else if (wsRole === "owner" || wsRole === "admin") role = "write";
else if (repo.collaboratorRole === "read" || wsRole === "member") role = "read";

if (body.op === "read") {
	if (repo.visibility === "public") return { allowed: true };
	if (role !== null) return { allowed: true };
	return { allowed: false, reason: "repository not found" };
}
if (role === "write") return { allowed: true };
if (!userId) return status(401, "authentication required");
return { allowed: false, reason: "not authorized to push to this repository" };

Two details reward attention. Private repos fail closed with "repository not found" - a non-member can't distinguish "doesn't exist" from "no access," so private repo names don't leak through error messages. And an unauthenticated write gets a 401, not 403 - because 401 is what makes a git client prompt for credentials and retry, while 403 would dead-end the push. Getting that one status code wrong is the difference between "enter your token" and a support ticket.

Service-to-service calls (denji and the CI runner to the API) ride an internal channel gated by a shared secret header. Symmetric and simple; per-service identity or mTLS is the upgrade when the service count or the threat model grows.

What's the shape of the whole thing?

One authority (the API owns every permission decision), one scoping convention (workspace_id on everything), one revocation story (sessions are rows), and boring primitives (argon2id, SHA-256, foreign keys). The known debts - unscoped PATs, app-level-only isolation, shared internal secret - are written down and ordered, which we'd argue is the realistic definition of a security roadmap: smallest honest system first, upgrades priced and sequenced.

FAQ

Why not row-level security from day one?

Because RLS adds a second place where access logic lives - policies that must agree with application queries - and while the schema is evolving weekly, that agreement is a standing tax. App-level scoping with cascade deletes and per-workspace unique indexes gives correct behavior now; RLS is the defense-in-depth layer to add when the model stabilizes.

Why server-side sessions instead of JWTs?

Instant revocation, no denylist infrastructure, and no signed-claims footguns - at the cost of a database lookup per request, which a session cache makes negligible. JWTs shine when many services must verify identity without a shared store; Rezee has exactly one authority anyway.

What happens when a workspace is deleted?

ON DELETE CASCADE from the workspace row removes everything it owns - repos, issues, docs, channels, memberships - in one transaction. Deliberate: tenant deletion should be complete and atomic, not a cleanup job that misses things.

How would agents fit this model?

Today an agent credential would be a normal PAT - meaning it acts as its owning user, unscoped, which is exactly why scoped tokens lead the roadmap. (We've since designed that scoped-token model - a capability grammar on the token, gated at this same authorize endpoint, fuzz-tested to zero privilege escalations.) The permission model agents deserve - own identity, narrow scopes, full attribution - is an extension of this architecture, not a rewrite of it. The identity half of that extension is exactly one enum on the users table - we've written up why that single decision carries the whole agent model, including what this post's membership rules give agents for free.