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

RLS is the net. The correctness guarantee is still explicit workspace_id filtering on every query — RLS is what catches the one query that slips through.

Every workspace-scoped HTTP request goes through this flow in the request middleware:

  1. The request includes an x-workspace-id: <uuid> header.
  2. The workspace-context middleware (a per-request hook) validates the UUID, confirms the workspace row exists, and sets the workspace context for the connection.
  3. Setting the context runs SELECT set_config('app.current_workspace_id', '<uuid>', false) on the connection.
  4. Every subsequent SQL query is filtered by RLS policies that compare each row’s workspace_id against the session value, read through a small STABLE function:
-- reads the session variable; empty/unset → NULL → matches nothing
CREATE OR REPLACE FUNCTION current_workspace_id() RETURNS uuid AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_workspace_id', true), '')::uuid;
END;
$$ LANGUAGE plpgsql STABLE;
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. A post-response hook clears the session variable so a pooled connection doesn’t leak state to the next request.

Because current_workspace_id() returns NULL when the session variable is empty, a request that forgets to set the context sees nothing, not everything — the fail-safe direction.

Crons, background workers, and the inbound /mcp surface never pass through the /api/ middleware, so they can’t rely on the per-request hook. Instead they wrap their reads in a transaction-scoped workspace-context helper, which sets the same variable transaction-locally (set_config(..., true), so it resets automatically at commit/rollback). A bare query on those paths would simply return zero rows under the restricted role — which is why services like the MCP access gate, retention, and harvester auth deliberately use the wrapper.

The migration that creates the application role grants carabase_app standard CRUD privileges (SELECT/INSERT/UPDATE/DELETE on public tables) but does not grant BYPASSRLS. So:

  • Migration role (superuser) — bypasses RLS by default; used for pnpm db:migrate, the seeder, and the backup pipeline.
  • Application role (carabase_app) — RLS enforced; this is the role production connects as (via DATABASE_URL).

In local dev and CI the connection often uses the owner/migration role for convenience, which bypasses RLS — so the regression test below explicitly SET ROLEs to carabase_app to exercise the policies that production runs under.

RLS is enabled, with a workspace-isolation policy, on every workspace-scoped table. The original RLS migration turned it on for the first 11 tables; each later migration that adds a workspace-scoped table adds RLS for it too, so the set grows with the schema (90+ tables today) — entities, edges, memories, daily notes, artifacts, file artifacts, folios and folio members, chat sessions and messages, integrations, sync rules, OAuth apps, curation, imports, agent task runs, feedback events, and so on.

The workspaces table itself is the one special case — its policy is keyed on id rather than workspace_id (a workspace row can’t carry its own workspace_id).

A security regression suite SET ROLEs to carabase_app and asserts that:

  • A cross-workspace SELECT by workspace_id returns 0 rows.
  • A cross-workspace SELECT by direct id returns 0 rows — the dangerous case, guarding against an endpoint that forgets to re-check the workspace when looking up by primary key.
  • An empty session variable yields 0 visible rows — a request that forgets to set the context sees nothing, not everything.
  • A cross-workspace INSERT is rejected by the WITH CHECK policy.
  • A cross-workspace UPDATE or DELETE silently affects 0 rows.
  • The workspaces table itself can’t be read across tenants.

If this test ever fails, do not loosen the assertions. The three failure modes it guards against are all hard blockers:

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

RLS is one layer of Carabase’s defense-in-depth posture, alongside AES-256-GCM credential encryption, webhook signature verification, rate limiting, and the propose-and-exit external-actions write surface. The primary trust boundary is still the Tailscale mesh — zero public ports — with everything here as the layered safety net inside it.