PostgreSQL · Retrieval-augmented generation · Security

Multi-Tenant RAG with PostgreSQL Row-Level Security

Enforce tenant isolation across documents, chunks, vector retrieval, citations, caches, background jobs, and database roles.

Published
Updated
Code examples
Illustrative
Reading time
4 minutes

Multi-tenant RAG needs two independent controls: the application must pass authenticated tenant context into every operation, and the database must reject rows outside that context. PostgreSQL row-level security can provide the second boundary, but only when table owners, privileged roles, connection pooling, ingestion jobs, vector queries, and caches are designed not to bypass it.

RLS is defense in depth, not a substitute for authenticating the caller or authorizing document access.

Start with the PostgreSQL boundary

When row security is enabled, normal row access must be allowed by a policy; if no applicable policy exists, PostgreSQL uses default deny. Superusers and roles with BYPASSRLS always bypass policies, and table owners normally bypass them unless FORCE ROW LEVEL SECURITY is enabled. These behaviors are defined in PostgreSQL 18 Row Security Policies.

That means the application role should not own its tables, be a superuser, or have BYPASSRLS.

CREATE TABLE passages (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id uuid NOT NULL,
  document_id uuid NOT NULL,
  body text NOT NULL,
  embedding vector(1536) NOT NULL
);

ALTER TABLE passages ENABLE ROW LEVEL SECURITY;
ALTER TABLE passages FORCE ROW LEVEL SECURITY;

CREATE POLICY passages_tenant_select ON passages
  FOR SELECT
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

CREATE POLICY passages_tenant_insert ON passages
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

Use separate policies for reads and writes so an ingestion bug cannot insert a row under another tenant. Apply equivalent policies to sources, versions, chunks, embeddings, evaluation cases, and citation records.

Scope tenant context to a transaction

With a connection pool, session state can leak into the next borrower. Begin a transaction, set tenant context locally, perform the complete operation, and commit or roll back:

BEGIN;
SELECT set_config('app.tenant_id', $1, true);

SELECT id, body
FROM passages
ORDER BY embedding <=> $2::vector, id
LIMIT 10;

COMMIT;

The third argument to set_config makes the value transaction-local. The pool still needs a guaranteed rollback path for errors. Validate the tenant identifier from authenticated server-side state; never accept a tenant ID from model output or an unsigned client field.

Use current_setting(..., true) so a missing value becomes null and the policy denies rows. A cast error may still occur for malformed values, which is appropriate to treat as a request failure.

Apply document-level permissions when tenant scope is not enough

Some tenants have private projects or user-scoped documents. Model memberships in a table and test them in policy, but understand the concurrency and performance consequences. PostgreSQL warns that policies consulting other tables can introduce race conditions if privileges change concurrently; its documentation discusses locking and security-definer alternatives in detail. See the RLS race-condition guidance.

Prefer policy predicates based on values already in the row when possible. For complex authorization, use a carefully audited function with a fixed search_path, minimal privileges, and tests proving it cannot broaden access.

Keep filtering inside vector retrieval

RLS predicates participate in the query, but approximate vector-search plans and selective filters need measurement. pgvector explains that approximate filtering occurs after index scanning and may return fewer rows; iterative scans, partial indexes, partitioning, or an exact scan over a filtered set may be more appropriate. See pgvector filtering.

Test every tenant-size cohort with EXPLAIN (ANALYZE, BUFFERS). Never disable RLS or fetch global candidates to “fix” recall. Choose an index strategy compatible with the authorization boundary.

Separate privileged maintenance from application traffic

Backups, migrations, index maintenance, and cross-tenant analytics may need broader access. Give each a distinct, auditable role and connection path. Do not reuse the privileged role for web requests, retrieval workers, evaluation jobs, or support tools.

Background ingestion must set the same tenant context as queries. A queue message should carry a signed or database-resolved source identity, not a tenant field the document content controls.

PostgreSQL notes that TRUNCATE, REFERENCES, referential-integrity checks, superusers, and BYPASSRLS do not behave like normal policy-filtered queries. Review these exceptions when writing migrations and tests.

Extend isolation beyond SQL

RLS cannot protect data already copied elsewhere. Include tenant and policy identity in:

  • embedding and reranking requests;
  • object-storage keys and access controls;
  • retrieval and answer cache keys;
  • traces, logs, and evaluation datasets;
  • citations and generated exports; and
  • deletion and retention jobs.

Never cache solely by question text. At minimum, bind a cache entry to tenant, principal or permission snapshot, corpus generation, retrieval policy, and model configuration.

Test negative cases first

Create two tenants with deliberately similar documents and unique canary strings. Verify each role cannot select, insert, update, cite, retrieve, cache-hit, export, or delete the other tenant’s canary. Run tests through the same pooled connection and application path used in production.

Also assert:

  • missing tenant context returns no rows or a controlled error;
  • a supplied tenant ID cannot override authenticated context;
  • the application role has neither ownership nor BYPASSRLS;
  • background workers set context transaction-locally;
  • support and admin access emits an audit event; and
  • backups use an explicit role and cannot silently omit RLS-filtered rows.

PostgreSQL’s row_security=off setting does not bypass policies; it causes an error when a query would be filtered, which is useful for detecting incomplete backups. This behavior is documented in the official RLS chapter.