Workspaces & Row-Level Security
Carabase is single-tenant by design — one host = one workspace = one user. So why is workspace tenancy a first-class concept in the schema?
Because it’s the safety backstop. Even though the product is single-tenant in practice, every table that holds user data has a workspace_id column and a Postgres Row-Level Security policy keyed on it. If a future feature ever introduces a second workspace (e.g. an “imported from a friend’s export” flow), or if application code ever forgets a WHERE workspace_id = ? clause, the database itself refuses to serve cross-workspace rows.
How it works
Section titled “How it works”Every workspace-scoped query goes through this flow:
- The HTTP request includes an
x-workspace-id: <uuid>header - The workspace-context middleware validates the UUID, looks up the workspace, and calls
setWorkspaceContext(workspaceId) setWorkspaceContextrunsSELECT set_config('app.current_workspace_id', '<uuid>', false)on the connection- Every subsequent SQL query is filtered by RLS policies that look like:
CREATE POLICY entities_workspace_isolation ON entities USING (workspace_id = current_workspace_id());
CREATE POLICY entities_insert ON entities FOR INSERT WITH CHECK (workspace_id = current_workspace_id());- The
onResponsehook clears the session variable so a pooled connection doesn’t leak state to the next request
The role split
Section titled “The role split”The migration that creates the carabase_app role grants it standard CRUD permissions but does not grant BYPASSRLS. So:
- Migration role (
postgres/ superuser) — bypasses RLS by default, used forpnpm db:migrate, the seeder, and the backup pipeline - Application role (
carabase_app) — RLS enforced; this is what production deployments should use
In dev and CI we connect as the migration role for simplicity. In production you should GRANT carabase_app TO carabase and SET ROLE carabase_app before serving traffic.
What’s protected
Section titled “What’s protected”22 tables have ENABLE ROW LEVEL SECURITY plus a workspace-isolation policy:
workspaces, users, agents, integrations, documents, memories, logs, chat_messages, entities, edges, agent_logs, daily_notes, workspace_settings, granola_cache, sync_rules, oauth_apps, curation_suggestions, curation_audit_log, harvester_devices, imports, agent_task_runs, plus their adjacent tables (folios, artifacts, commits, artifact_entities, chat_sessions, file_artifacts).
The workspaces table itself is special — its policy is keyed on id rather than workspace_id (since you can’t have a workspace_id on the workspaces row).
Regression test
Section titled “Regression test”A vitest suite at src/__tests__/security/rls-isolation.test.ts SET ROLEs to carabase_app and asserts that:
- Cross-workspace
SELECTbyworkspace_idreturns 0 rows - Cross-workspace
SELECTby directidreturns 0 rows (the dangerous case — guards against an endpoint that forgets to re-check workspace when looking up by primary key) - Empty session variable → 0 rows visible (a request that forgets to call
setWorkspaceContextsees nothing, not everything) - Cross-workspace
INSERTis rejected by theWITH CHECKpolicy - Cross-workspace
UPDATEandDELETEsilently affect 0 rows
If this test ever fails, do not loosen the assertions. The three failure modes it guards against are all v0.1 launch blockers:
- A new workspace-scoped table forgets
ENABLE ROW LEVEL SECURITY - A policy is dropped or modified during a future migration
- Someone grants
BYPASSRLSto thecarabase_approle