July 29, 2026
One Tenant Just Saw Another Tenant’s Documents
Tenant A asks a question, your RAG returns Tenant B’s document. That is a breach report. Here is the isolation pattern.

By Raza Hussain
7 min read
Tenant A asks a question. Your RAG retrieves Tenant B's document.
That is not a bug ticket. That is a breach report, and someone on your team has to write it.
I have seen this happen in a multi-tenant RAG isolation setup where the embedding query was correct and the tenant leak still got through, because the vector search never checked which tenant owned the row it returned. nearest_neighbors finds the closest vector in the whole table, every tenant's rows included, unless you scope it yourself.
Application-level scoping catches the happy path and misses the one console session, the one background job, the one raw SQL debug query that forgets the where(tenant_id:) clause. Row-Level Security closes that gap at the database, not the query builder.
Rails request
↓
ActiveRecord::Base.transaction
↓
set_config('app.tenant_id', ..., true)
↓
Postgres RLS policy (FORCE)
↓
nearest_neighbors, scoped to one tenant's rowsRails request
↓
ActiveRecord::Base.transaction
↓
set_config('app.tenant_id', ..., true)
↓
Postgres RLS policy (FORCE)
↓
nearest_neighbors, scoped to one tenant's rows- Add a
tenant_idcolumn to every table that stores embeddings,NOT NULL, no default. - Enable Postgres Row-Level Security on that table, and immediately
FORCEit too. - Write a policy that checks
current_setting('app.tenant_id'), not an application-layerWHERE. - Set the tenant GUC (a Postgres session configuration variable) with
set_configinside the same transaction as the query, every request. - If you are behind PgBouncer in transaction mode, verify you used
SET LOCALsemantics, not a bare session-scopedSET.
Application-Level Scoping Is Not a Security Boundary
The first version of tenant scoping most Rails apps ship is a default scope or a before_action that adds where(tenant_id: current_tenant.id) to every query. That works until it does not.
A Sidekiq job that queries Document directly, without the controller's scope in front of it, sees every tenant's rows. So does a Rails console session on production, and so does a raw SQL debug query someone pastes into a Slack thread at 2am.
None of those paths are malicious. All of them bypass a scope that lives in Ruby, not in the database.
class Document < ApplicationRecord
has_neighbors :embedding
end
class DocumentsController < ApplicationController
def search
query_vector = Embedder.embed(params[:question])
results = current_tenant.documents.nearest_neighbors(:embedding, query_vector, distance: "cosine").first(5)
end
endclass Document < ApplicationRecord
has_neighbors :embedding
end
class DocumentsController < ApplicationController
def search
query_vector = Embedder.embed(params[:question])
results = current_tenant.documents.nearest_neighbors(:embedding, query_vector, distance: "cosine").first(5)
end
endcurrent_tenant.documents scopes correctly here. The scope disappears the moment anyone queries Document directly instead of through the association, and RAG pipelines have more of those direct paths than a typical CRUD app: embedding backfill jobs, admin debug tools, evaluation scripts that pull raw chunks to check retrieval quality.
- A
before_actionscope only protects requests that go through that controller action. - Background jobs, console sessions, and ad hoc scripts routinely query models directly, with no controller in the call path.
nearest_neighborssearches the whole table by default. A missingwhere(tenant_id:)anywhere in that path returns another tenant's closest-matching document.
The RLS Policy Behind Real Multi-Tenant RAG Isolation
Row-Level Security moves the tenant check into Postgres itself, so it applies no matter how the query was built.
ALTER TABLE ... ENABLE ROW LEVEL SECURITY (Postgres 9.5+) is the first step, and it is not the whole step.
Enabling RLS still lets the table owner bypass it. In most Rails setups, the database user your app connects as is also the table owner, which means ENABLE alone protects nothing in your own application.
The policy needs a real column to check, not just a session variable to compare against.
add_column ... null: false with no default fails outright on a table that already has rows, because Postgres has to validate every existing row against the constraint and every existing row is missing the value.
# Migration 1
add_column :documents, :tenant_id, :uuid
add_check_constraint :documents, "tenant_id IS NOT NULL", name: "documents_tenant_id_null", validate: false
# Backfill, then migration 2
Document.in_batches.update_all(tenant_id: DEFAULT_TENANT_ID) # backfill per your actual tenant mapping
validate_check_constraint :documents, "documents_tenant_id_null"# Migration 1
add_column :documents, :tenant_id, :uuid
add_check_constraint :documents, "tenant_id IS NOT NULL", name: "documents_tenant_id_null", validate: false
# Backfill, then migration 2
Document.in_batches.update_all(tenant_id: DEFAULT_TENANT_ID) # backfill per your actual tenant mapping
validate_check_constraint :documents, "documents_tenant_id_null"add_check_constraint with validate: false adds the constraint without locking the table to scan every row up front. validate_check_constraint does that scan afterward, once the backfill has already given every row a value.
A single change_column_null does the validation scan inline and holds a lock for as long as that scan takes, which on a large embeddings table is exactly the migration you do not want running during a deploy window.
If documents is a brand new table with no rows yet, skip both extra steps and add the column null: false directly.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);FORCE is the line that closes the table-owner bypass. Skip it and the policy above compiles, applies to every role except the one your Rails app actually uses, and you find out in an incident review, not a test.
USING guards what a query can read, update, or delete. WITH CHECK guards what a query can insert or turn a row into.
Postgres reuses USING as the implicit WITH CHECK on a policy like this one that covers every command, so leaving it out here would not have been a hole. Writing it explicitly is still the difference between relying on a default and stating the write boundary on purpose.
With no policy at all, RLS on an enabled table defaults to deny, not allow. That fail-closed behavior is what makes the pattern worth using, once FORCE is in place to make it apply to your own connection.
RLS is not a complete perimeter by itself. TRUNCATE and foreign key REFERENCES checks are not subject to row security at all, so a tenant boundary that depends only on RLS still needs TRUNCATE revoked from the app role separately.
FORCE also changes what your backup tooling sees. pg_dump and logical replication run as whatever role you point them at, and once that role is subject to FORCE, a dump or replication stream that does not carry a tenant GUC exports zero rows, not an error.
Run backups as a role with BYPASSRLS, since that attribute wins over FORCE unconditionally, or you find out your backup was empty the day you need to restore it.
ENABLE ROW LEVEL SECURITYalone does not protect your own app's queries if your app's database user owns the table.FORCE ROW LEVEL SECURITYis the line that makes the policy apply to the table owner too.TRUNCATEand foreign keyREFERENCESbypass RLS entirely. RevokeTRUNCATEfrom the app role if RLS is your only tenant boundary.pg_dumpand logical replication also hitFORCEand default-deny. Use aBYPASSRLSrole for backups, or a scoped dump silently exports nothing.
Setting the Tenant Variable Is Where This Breaks in Production
current_setting('app.tenant_id') reads that GUC back, and the policy above is only as good as how reliably your application sets it on every single request.
On a genuinely fresh connection, call current_setting on a variable that was never set, with no second argument, and Postgres raises an error. The query does not run, it stops.
On a connection reused from a pool, the failure looks slightly different but lands the same place. Once any transaction on that backend has set app.tenant_id, Postgres registers it as a known custom parameter for the life of that backend.
A later request that forgets to set it does not hit unrecognized parameter anymore, it reads back an empty string, and ''::uuid raises instead. Either way, a forgotten GUC fails loud, not silent.
I chose the erroring form deliberately over current_setting('app.tenant_id', true), which returns NULL on a forgotten GUC and relies on the policy comparison failing closed on NULL. Both are fail-closed.
I want the query to blow up in a way that pages someone, not return zero results that look like an empty search and get quietly retried.
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(["SELECT set_config('app.tenant_id', ?, true)", current_tenant.id.to_s])
)
results = Document.nearest_neighbors(:embedding, query_vector, distance: "cosine").first(5)
endActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(["SELECT set_config('app.tenant_id', ?, true)", current_tenant.id.to_s])
)
results = Document.nearest_neighbors(:embedding, query_vector, distance: "cosine").first(5)
endset_config is a function, not a statement, which is why it can take a bound parameter through sanitize_sql_array instead of a string-interpolated SET LOCAL app.tenant_id = '#{tenant_id}'. Interpolating a tenant ID straight into SQL text is the same class of mistake this whole pattern exists to prevent.
The third argument to set_config has to be true. That is what makes it transaction-local, equivalent to SET LOCAL, and it is the one detail that turns this pattern from a fix into a new leak if you get it wrong.
I did not take that argument seriously until I read what happens behind a connection pooler.
PgBouncer Is Where a Correct Policy Leaks Anyway
If PgBouncer sits in front of Postgres in transaction pooling mode, the backend connection gets handed to a different client at every COMMIT. Your tenant GUC either travels with the transaction or it does not exist for the next request that grabs that connection.
SET LOCAL, and set_config with true as the third argument, revert automatically at COMMIT. That is exactly the behavior a recycled backend needs.
A bare SET app.tenant_id = ..., or set_config with false, persists on the session. On a direct connection that is merely sloppy.
Behind a transaction-mode pooler, that session-scoped value can still be sitting on the backend when the next request, for a different tenant, picks it up.
That is the exact failure this article opened with, except caused by the isolation mechanism itself instead of a missing where clause. A correctly written RLS policy, driven by a GUC set the wrong way, leaks Tenant A into Tenant B's transaction.
SET LOCALandset_config(..., true)are transaction-scoped and safe behind a transaction-mode pooler, because they revert atCOMMITregardless of which client reuses the connection next.- A bare
SETorset_config(..., false)is session-scoped, and can leak across tenants on a recycled backend connection under PgBouncer transaction pooling. - Set the tenant GUC inside the same transaction as the query, every request. Setting it once at connection-open time does not survive backend recycling.
When RLS Is More Than This Needs
A single-tenant app, or a multi-tenant app where every tenant shares the same embeddings by design (a shared knowledge base, not per-tenant documents), gets nothing from this pattern and pays a real cost for it.
Every query against an RLS-protected table carries the policy evaluation, and every write path needs the transaction-and-GUC dance shown above or it silently hits the default-deny wall.
I would not add RLS to a side project with one Postgres role and no real tenant boundary. The operational overhead, remembering to wrap every write in a transaction with the GUC set, is not worth it until there is a second tenant whose data actually needs to stay separate.
The alternative most teams reach for first is a default_scope plus a linter rule banning direct model access outside of scoped associations. That catches the accidental case.
It does not catch the Rails console, and it does not catch a job written by someone who has not read the linter rule yet.
- Skip RLS entirely for single-tenant apps or shared-knowledge-base RAG where there is no tenant boundary to protect.
- RLS adds real overhead: every write needs a transaction plus a correctly-scoped GUC, or it hits default-deny.
- A
default_scopeand a linter rule catch the accidental query. They do not catch a console session or an unreviewed job.
Tenant A asking a question and getting Tenant B's document back is not a retrieval bug. It is the database returning exactly what your query asked for, because nothing at the database layer ever asked which tenant owned the row, and a policy with FORCE behind it is what makes that question mandatory instead of optional.
If you are running pgvector in production, the next thing worth checking is whether your index dimension ceiling matches the embedding model you are actually using.
Related reads