Skip to content

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.

Every workspace-scoped query goes through this flow:

  1. The HTTP request includes an x-workspace-id: <uuid> header
  2. The workspace-context middleware validates the UUID, looks up the workspace, and calls setWorkspaceContext(workspaceId)
  3. setWorkspaceContext runs SELECT set_config('app.current_workspace_id', '<uuid>', false) on the connection
  4. 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());
  1. The onResponse hook clears the session variable so a pooled connection doesn’t leak state to the next request

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 for pnpm 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.

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).

A vitest suite at src/__tests__/security/rls-isolation.test.ts SET ROLEs to carabase_app and asserts that:

  • Cross-workspace SELECT by workspace_id returns 0 rows
  • Cross-workspace SELECT by direct id returns 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 setWorkspaceContext sees nothing, not everything)
  • Cross-workspace INSERT is rejected by the WITH CHECK policy
  • Cross-workspace UPDATE and DELETE silently 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:

  1. A new workspace-scoped table forgets ENABLE ROW LEVEL SECURITY
  2. A policy is dropped or modified during a future migration
  3. Someone grants BYPASSRLS to the carabase_app role