Skip to documentation

Platform

Tenancy & security

eli.ai is multi-tenant: one deployment can host many isolated workspaces (a personal vault, several client engagements). Isolation is not a WHERE clause you have to remember — it is enforced by the database itself. This page is the load-bearing security contract.

The request path

Membership check → transaction-local GUC → RLS → fail closed.

Every tenant table is FORCE-RLS

Every tenant table carries a workspace_id (the leading index column) and a Postgres row-level-security policy. Critically, the policy uses FORCE ROW LEVEL SECURITY. Plain RLS is bypassed by a table's owner — and the app connects as the role that owns the tables — so without FORCE, every policy would be a silent no-op. FORCE makes the policy apply to the owner too.

Policies compare workspace_id against a transaction-local GUC and are written missing_ok, so when the context is unset they return zero rows (fail closed) instead of throwing or leaking.

withWorkspace() is the only door

All tenant DB access flows through one helper. It opens a transaction, sets app.workspace_id transaction-locally, and runs your callback under RLS:

every tenant query looks like thists
await withWorkspace(workspaceId, async (tx) => {
  // set_config('app.workspace_id', workspaceId, true) already ran.
  // RLS scopes every row to this workspace, regardless of the WHERE clause.
  return tx.select().from(documents);
});

Hard invariant: no I/O inside the callback

No LLM, network, or file I/O inside a withWorkspace transaction. The pattern is always retrieve → commit → (call the provider) → short write transaction. Holding a pooled connection open across a 60-second model stream is the failure that freezes the app during a live demo.

Identity comes from the URL, never the session

The workspace is always derived from the URL path (/w/[workspaceId]/…), validated against membership by requireWorkspaceAccess, which returns the caller's role and throws a WorkspaceAccessError (with a .status) otherwise. Session-derived scoping is banned: a consultant with two client tabs open would otherwise autosave client X's notes into client Y's workspace — and RLS would happily permit it, because the write is "correctly" scoped to the wrong tenant.

One user, many workspaces

Multi-tenancy isn't only for separate customers — the same user routinely holds several namespaces (a personal vault plus one workspace per client engagement), and the isolation contract between their own workspaces is exactly the same FORCE-RLS wall as between strangers: no document, entity, conversation, or connector ever crosses, and membership is checked per workspace on every request.

  • Switcher— the sidebar header carries a workspace switcher listing every workspace you belong to (with your role), and a "create workspace" action that provisions a fresh, empty namespace you own.
  • API — the switcher is backed by GET /api/workspaces(the caller's workspaces: id, name, slug, role) and POST /api/workspaces (create one, session-authenticated — this is control-plane data about the user, not tenant data, so it lives outside /api/w/:workspaceId).
  • Continuity — the active workspace is remembered in a cookie; the /open entry point routes you back to your last-used workspace (membership-checked), falling back to your first.
  • Renaming — owners and admins rename a workspace under Settings → General.

The isolation canary

A permanent CI test seeds two workspaces and proves, against the real migrated schema as a non-superuser table owner, that:

  • cross-workspace reads and writes are impossible;
  • an unset workspace context reads zero rows (fail closed);
  • a catalog check asserts every table with a workspace_id has both rowsecurity and forcerowsecurity — so a new table physically cannot ship without RLS.

Any failure there is a release blocker.

How the pieces enforce it

  • Agents & tools — the tool registry injects workspaceId into every KB tool call; the model never supplies scope, so prompt injection can't cross workspaces. See Tools, MCP & HITL.
  • Providers — the per-workspace allowlist is enforced in the routing layer, so a residency guarantee is code, not convention. See Multi-provider AI.
  • API keys— bearer keys are workspace-scoped and hashed; a key resolves to exactly one workspace, and a run against another workspace's data returns 404 under RLS. See Authentication.
  • Instance-per-client — for contracts that forbid commingled data or backups, the compose topology makes a dedicated instance cheap.