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.
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
mirror—docs/architecture/public-overview-diagrams.md:65mirror—docs/architecture/README.md:109canonical—src/components/docs/diagrams.ts:25route/docs/architecturemirror—src/app/(docs)/docs/concepts/tenancy/page.tsx:25route/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:
Hard invariant: no I/O inside the callback
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) andPOST /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
/openentry point routes you back to your last-used workspace (membership-checked), falling back to your first. Accounts with zero workspaces go to/get-startedinstead. - 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_idhas bothrowsecurityandforcerowsecurity— 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
workspaceIdinto 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.