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

Tenancy and RLS

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

Rendering diagram

Downloads

Concepts

  • Tenancy and RLS
  • Public overview diagrams
  • Tenancy & security (load-bearing)
  • Architecture
  • Tenancy

Keywords

  • queries run under RLS
  • workspace_id = app.workspace_id?
  • membership check (role)
  • zero rows (fail closed)
  • unset / mismatch
  • Request /w/:workspaceId/…
  • withWorkspace(workspaceId, fn)
  • transaction-local
  • policy
  • rows
  • match
  • requireWorkspaceAccess(workspaceId)
Source and generation provenance

Status: current

Generated at: 2026-08-12T23:42:46.846Z

Source hash: 7eec09d714e2b4ff19bb93e99366904e9936f44b18dd2d17c224c0aba6710521

Metadata payload hash: da2480e4d98f4fa5d25c26125f94409e5db4b424d0a522a524c6351f676e9bb4

Canonical appearance

src/components/docs/diagrams.ts:25 route /docs/architecture

All appearances

  • mirrordocs/architecture/public-overview-diagrams.md:65
  • mirrordocs/architecture/README.md:109
  • canonicalsrc/components/docs/diagrams.ts:25 route /docs/architecture
  • mirrorsrc/app/(docs)/docs/concepts/tenancy/page.tsx:25 route /docs/concepts/tenancy

Generation versions

App: eli-ai 0.1.0

Mermaid: 11.16.0 · Mermaid CLI: 11.16.0

Node: v26.3.1 · Yarn: 4.17.1

Renderer config hash: 68c10966fe84406ee626034d58bfabd555df9f65f691204b7c46db24038da101

Renderer theme hash: c80287a78d80ad63d27bd5ca348b2ef9a7e2f44da289e436be6484ea28a1b033

Adapter versions: diagramGenerator=2, drawioFlowchart=1, drawioGantt=1, drawioSequence=1, drawioState=1

Full sidecar JSON: tenancy-and-rls-7eec09d7.json

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. Accounts with zero workspaces go to /get-started instead.
  • 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.