home/blog/implementing-rag-row-level-security-for-multi-tenant-ai
·7 min read·RAG row-level security · multi-tenant AI · PostgreSQL RLS

Implementing RAG Row-Level Security for Multi-Tenant AI

Share
Implementing RAG Row-Level Security for Multi-Tenant AI

The Multi-Tenant RAG Isolation Problem

A multi-tenant RAG system without row-level security is a data breach waiting to happen. Tenant A asks a question. The retrieval layer searches the entire vector store. The LLM generates an answer using Tenant B's documents. The breach is silent, continuous, and invisible to the application layer.

Application-level filtering does not solve this. If the retrieval step returns chunks from the wrong tenant, the LLM has already consumed them before any application filter runs. The isolation must happen at the database level, before retrieval, not after.

PostgreSQL Row-Level Security (RLS) is the standard mechanism for enforcing tenant isolation at the storage layer. When combined with vector search for RAG, RLS ensures that retrieval queries only touch rows the current tenant is authorized to see. (PostgreSQL RLS Documentation)

For the broader RAG security architecture, see secure enterprise RAG architecture.

Why Application-Level Filtering Fails

The common approach is to add a tenant_id column to the application query and filter in the application code. This fails in three ways:

1. Retrieval bypass. Vector search operates on embeddings, not SQL rows. If the vector index includes all tenants, a similarity search can return chunks from any tenant before the application filter applies. The LLM sees the wrong data.

2. Developer error. A single missing WHERE tenant_id = ? clause in one query path exposes all tenant data. Application-level security requires every developer to remember the filter every time. Database-level security enforces it regardless of application code.

3. Prompt injection. An attacker who can influence the query string can attempt to bypass application filters. RLS policies are evaluated by the database engine and cannot be bypassed by application-level input manipulation.

Tenant Isolation Architecture

User Request (with tenant context)
         |
         v
  Set tenant context (SET app.tenant_id)
         |
         v
  Database connection with RLS policy
         |
         v
  Vector similarity search
  (RLS automatically filters by tenant_id)
         |
         v
  Only tenant-scoped chunks returned
         |
         v
  LLM generates answer from scoped chunks
         |
         v
  Response to user (no cross-tenant data)

The key difference from application-level filtering: the tenant context is set at the database session level, not in the query. Every query in that session is automatically scoped. No developer needs to remember to add a filter.

Step 1: Enable RLS on the Documents Table

Assume a table that stores document chunks with their embeddings:

CREATE TABLE document_chunks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL,
  content TEXT NOT NULL,
  embedding VECTOR(1536),
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT now()
);

Enable RLS and create a policy that restricts access based on a session variable:

ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON document_chunks
  USING (tenant_id = current_setting('app.tenant_id')::UUID);

The USING clause is evaluated for every row. If tenant_id does not match the session variable, the row is invisible. (PostgreSQL RLS Documentation)

Step 2: Set Tenant Context Per Request

Before executing any query, set the tenant context for the database session:

async function setTenantContext(client, tenantId) {
  await client.query('SET app.tenant_id = $1', [tenantId]);
}

async function retrieveChunks(client, queryEmbedding, limit = 5) {
  const result = await client.query(`
    SELECT id, content, metadata
    FROM document_chunks
    ORDER BY embedding <-> $1
    LIMIT $2
  `, [JSON.stringify(queryEmbedding), limit]);
  return result.rows;
}

The SET command configures the session. Every subsequent query on document_chunks is automatically filtered by the RLS policy. The vector search query does not include tenant_id in its WHERE clause. The database enforces isolation transparently.

Step 3: Use Supabase Auth for Automatic Tenant Context

If you are using Supabase, RLS policies can reference the authenticated user directly, eliminating the need for manual session variables:

CREATE POLICY tenant_isolation ON document_chunks
  USING (
    tenant_id = (
      SELECT tenant_id FROM profiles
      WHERE id = auth.uid()
    )
  );

Supabase automatically sets auth.uid() based on the JWT token in the request. The RLS policy resolves the tenant from the user profile. No application code is needed to set tenant context. (Supabase RLS Documentation)

Step 4: Handle Connection Pooling Correctly

Connection pooling breaks session-level variables. If SET app.tenant_id is executed on a pooled connection, the next request might reuse that connection with the previous tenant's context still set.

Two solutions:

Option A: Reset context after each request.

async function withTenantContext(pool, tenantId, fn) {
  const client = await pool.connect();
  try {
    await client.query('SET app.tenant_id = $1', [tenantId]);
    return await fn(client);
  } finally {
    await client.query('RESET app.tenant_id');
    client.release();
  }
}

Option B: Use transaction-level scope.

await client.query('BEGIN');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`);
// queries here
await client.query('COMMIT');

SET LOCAL scopes the variable to the current transaction. When the transaction commits or rolls back, the variable is automatically reset. This is safer for pooled connections.

Step 5: Test the Isolation Boundary

Verify that RLS actually blocks cross-tenant access. Do not assume the policy works. Test it:

-- Set tenant context to Tenant A
SET app.tenant_id = 'tenant-a-uuid';

-- Attempt to read Tenant B's chunks
SELECT count(*) FROM document_chunks
WHERE tenant_id = 'tenant-b-uuid';
-- Expected: 0 (RLS blocks the row)

-- Verify Tenant A can read own chunks
SELECT count(*) FROM document_chunks
WHERE tenant_id = 'tenant-a-uuid';
-- Expected: N (RLS allows own rows)

Run this test as part of your CI pipeline. A regression in RLS policy logic can silently expose tenant data. For production verification of AI systems, see llmverify. For broader AI compliance program design, see the seven layers of AI compliance.

Tenant Onboarding Checklist

  • Create tenant record in tenants table with unique UUID
  • Create tenant admin user profile linked to tenant UUID
  • Verify RLS policy blocks cross-tenant access for the new tenant
  • Run isolation test: new tenant cannot read existing tenant data
  • Configure audit logging for the new tenant's data access patterns
  • Set up monitoring alerts for cross-tenant access attempts
  • Document the tenant's data retention and deletion policy
  • Verify vector search returns only tenant-scoped results

Common RLS Failure Modes

Failure ModeCauseFix
Cross-tenant data in retrieval resultsRLS not enabled or policy missingVerify ENABLE ROW LEVEL SECURITY and policy exists
Tenant context leaks between requestsConnection pooling without resetUse SET LOCAL in transactions or RESET in finally block
RLS bypassed by superuserPostgreSQL superusers bypass RLS by defaultUse FORCE ROW LEVEL SECURITY or non-superuser roles
Vector index includes all tenantsIndex built before RLS policyRebuild index after enabling RLS or use partial index
Performance degradationRLS policy evaluated per rowAdd index on tenant_id column

FAQ

What is RAG row-level security? RAG row-level security applies database-level tenant isolation to a Retrieval-Augmented Generation system. Instead of filtering tenant data in application code, RLS policies in PostgreSQL ensure that retrieval queries only return chunks belonging to the current tenant. This prevents cross-tenant data leakage at the storage layer.

Does PostgreSQL RLS work with vector search? Yes. PostgreSQL RLS policies apply to all queries on the table, including vector similarity searches using pgvector. The RLS policy filters rows before the vector index returns results, so that that retrieval only touches tenant-scoped embeddings.

How do I prevent tenant context leaks with connection pooling? Use SET LOCAL within a transaction to scope tenant context to that transaction. When the transaction commits, the variable is automatically reset. Alternatively, explicitly call RESET app.tenant_id in a finally block before releasing the connection back to the pool.

Can Supabase handle RAG row-level security automatically? Yes. Supabase RLS policies can reference auth.uid() from the JWT token, automatically resolving the tenant from the user profile. No manual session variable setting is required. See the Supabase RLS documentation for implementation details.

Should I use application-level filtering instead of RLS? No. Application-level filtering requires every developer to remember the tenant filter in every query. A single missing filter exposes all tenant data. RLS enforces isolation at the database level regardless of application code. Application filtering can supplement RLS but should not replace it.

How do I test that RLS is working? Set the tenant context to Tenant A, then attempt to query Tenant B's rows. The query should return zero rows. Run this test as part of your CI pipeline to catch RLS policy regressions before they reach production.

Get new articles in your inbox

Occasional emails when I publish something worth reading. Unsubscribe anytime.

Subodh KC
Author

Subodh KC

Enterprise AI Advisor & AI Systems Architect. Former Sr. Program Manager, HP Inc. Founder of HAIEC - High Assurance In Every Consequence. Builds production AI systems from decision through operation.

AboutServicesHAIEC
← all articles
Share
AI Advisor →