Row-Level Security for Multi-Tenant SaaS: Why We Force RLS on Every Table
Multi-tenant SaaS has one non-negotiable rule: tenant A must never see tenant B's data. Most applications enforce it with a where tenant_id = ? clause added by the ORM. That works until one query forgets it. When agents are calling tools that turn into queries, "until" can arrive sooner.
What RLS does
PostgreSQL row-level security attaches policies to tables. Every query, from any code path, is filtered by the policy before rows are returned or modified. The application tells the database which tenant is current; the database does the rest.
An illustrative example:
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE companies FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON companies
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
If no workspace is set, the setting is null, the comparison is never true, and the query sees no rows.
Forced, not optional
Enabling RLS is not enough. By default a table's owner bypasses its policies, and superusers and roles with BYPASSRLS always do. AI PRO CRM enables and forces row-level security on every tenant table, keyed on the workspace. The application's runtime database role is not a superuser, does not have BYPASSRLS and owns no tables, so there is no path around the policies for application code. These properties are checked on every deploy.
The pre-tenant problem
Some operations happen before a tenant is known: login, credential lookup, workspace discovery. Keep these exceptions few and written down, rather than sprinkling bypasses through the codebase.
Connection pooling
Poolers such as PgBouncer in transaction mode reuse connections, so a session-level setting can leak into the next request. Set the tenant transaction-locally:
BEGIN;
SELECT set_config('app.workspace_id', '3f2b8c1e-5d4a-4e7b-9c1f-2a6d8e0b7c45', true);
-- tenant queries here
COMMIT;
The third argument, true, scopes the setting to the current transaction. AI PRO CRM sets its tenant context transaction-locally.
Testing RLS properly
- Run tests as the same unprivileged role the application uses, so a test cannot pass by accident under a privileged account.
- Include a test that runs a query with no tenant set and asserts zero rows.
- Include a test that attempts a cross-tenant update and asserts zero rows affected.
Why this matters for agents
Agents act on prompts nobody on your team wrote. RLS holds even when a tool has a bug or a prompt is hostile.
FAQ
Does RLS hurt performance?
Policies add a predicate. With the tenant column indexed, the cost is usually small.
Is shared-schema tenancy with RLS as safe as schema-per-tenant?
It can be, and it is often simpler to get right: one set of migrations and one set of policies.