JOURNAL
Multi Tenant RAG: Enforce Access Control Before Retrieval
A reference architecture for tenant isolation, document ACLs, permission revocation, and authorization tests in RAG systems.

On this page
RAG access control should determine which documents a caller may use before their content reaches a reranker or language model. In a multi tenant RAG application, enforce both tenant membership and document permissions, carry those permissions into every chunk, and recheck authorization when serving cached answers or citations. A prompt telling the model to respect confidentiality cannot replace that boundary.
This article presents a reference architecture and a small PostgreSQL demonstration. It is a design to review and test, rather than a claim that I deployed this system or measured a production outcome. It extends the operational concerns in RAG in Production with an explicit authorization contract.

Start with a trusted identity
Assume that an authenticated tenant A user can submit arbitrary questions, guess document identifiers, manipulate request fields, and upload content containing hostile instructions. Also assume that ordinary implementation mistakes can omit filters, reuse a cache entry, or leave permissions stale in an index. The target is broader than preventing an obviously malicious question: every path that handles protected content must preserve the same access decision.
The application server verifies authentication and resolves the principal, active tenant, and current memberships from trusted identity and authorization services. A tenant identifier supplied by the browser is a requested context, not proof of membership. The server validates it before constructing a search filter or setting database session context.
Retrieved text is untrusted data. A document saying “ignore access restrictions” must not change the principal or grant tool permissions. OWASP describes direct and indirect prompt injection and notes that RAG alone does not eliminate this risk. Keep authorization decisions outside model-generated instructions. OWASP prompt injection guidance.
Make the retrieval boundary explicit
Use an authorization predicate equivalent to:
caller belongs to requested tenant
AND document belongs to requested tenant
AND caller has current read permission on document
Apply it to every retrieval route: vector search, keyword search, hybrid search, document-by-ID endpoints, and follow-up queries. For this reference architecture, an empty or unavailable identity produces no protected results. An authorization service failure should produce a controlled error, rather than retrying without the predicate.
Tenant isolation and document ACLs solve different problems. Tenant membership does not imply permission to every document within that tenant. Conversely, a matching document identifier or principal string must not bypass the tenant boundary. Keep tenant-scoped identifiers in joins and references so an accidental ID collision cannot link a chunk to another tenant’s document.
Microsoft’s security-filter pattern attaches principal metadata to indexed documents and applies a filter at query time. Its documentation explicitly distinguishes matching identity strings from authenticating a user; the trusted application must supply the correct principals. Azure AI Search security filters.
Azure also documents built-in document-permission approaches whose availability depends on the source and feature. Evaluate the selected permission model and its current support before choosing an integration; do not assume that ordinary service-level query access provides document-level isolation. Document-level access overview.
Preserve permissions through chunking and ranking
Each chunk needs a stable parent document ID, tenant ID, content version, and permission version. If the search index stores ACL metadata, each child must inherit the parent’s permissions. Rechunking must not create temporarily unprotected records. A missing permission field should quarantine the chunk until its metadata is complete.
The authorized candidate set is the only input to a reranker. Filtering after reranking is too late if an external reranking service already received forbidden text. The same rule applies to summarizers, model context, debug traces, and evaluation recordings. Audit identifiers and decisions where possible instead of storing complete private passages.
Authorization narrows the searchable corpus, which also affects relevance. Approximate nearest-neighbor retrieval with selective filters needs measurement against an exact-search baseline over the authorized corpus. Measure recall, latency, and empty-result rates for realistic ACL distributions; increasing candidate counts is not a universal guarantee. Azure documents how vector filter placement affects recall and performance. Vector query filters.
A PostgreSQL boundary you can inspect
The companion rag-access-control-demo.sql creates tenant memberships, document grants, and chunks in an isolated transaction. Its read policy requires all three conditions above. It uses no vector extension because its purpose is to demonstrate visibility, independently of an embedding model or ranking algorithm.
PostgreSQL row-level security can restrict normal row reads. Owners normally bypass it unless forced; superusers and roles with BYPASSRLS remain exempt. The demo therefore uses FORCE ROW LEVEL SECURITY and tests as a separate app_runtime role with neither exemption. PostgreSQL row security.
The trusted server would establish request context inside a transaction using bound parameters:
SELECT set_config('rag.tenant_id', $1, true),
set_config('rag.principal_id', $2, true);
SELECT document_id, chunk_id, body FROM rag_acl_demo.chunks;
These settings are context, not credentials. A database client able to issue arbitrary SQL as the application role can change them. Never expose that role directly to browsers, and prevent SQL injection. Bind the values, validate membership on the server, and use transaction-local settings so pooled connections do not carry a previous request’s identity forward.
Treat revocation as an authorization event
An asynchronous index can lag behind the source ACL. Updating the source record and scheduling reindexing does not immediately erase a stale indexed grant. This design uses the authoritative authorization store to validate candidate IDs before fetching protected passages and again before releasing an answer. The index filter reduces candidates; it is not the final authority while permissions can be stale.
If the current permission version cannot be obtained, fail closed. Define the ordering between revocation and response release, including in-flight requests and streaming. Once text has reached a caller, later revocation cannot recall it. Strict revocation requirements may need coordinated request cancellation or a serialized release check; a long-running transaction snapshot is not an immediate freshness guarantee.
Cache entries need the tenant, principal or equivalent entitlement scope, ACL version, query, and retrieval/model configuration in their key. Versioning only helps if the current version is checked before a cache hit is returned. Revalidate document dependencies and invalidate affected answers on revocation. Shared semantic caches need the same boundary as ordinary answer caches.
Authorize citations and test denial
A citation is another disclosure path. Titles, snippets, filenames, and download links can reveal restricted information even when the answer body looks harmless. Build citations from authorized evidence, and make the destination endpoint check current permission on every request. Avoid treating a previously issued URL as permanent permission.
The SQL demo raises an exception if missing identity reveals a chunk, tenant A sees tenant B, a same-tenant restricted document is visible, or a revoked grant still allows a subsequent read. It also verifies the authorized positive case. Run setup in a disposable database as a superuser; protected reads are tested only as the ordinary application role. The script rolls back its schema and role on success.
Application evaluations should additionally exercise stale index grants, cache hits after revocation, forged citation IDs, pooled-session reuse, and unavailable authorization services. Assert that forbidden evidence never reaches the reranker or model, rather than judging only the final answer. These denial cases belong beside relevance and groundedness checks in Evals for Agentic Systems.
The acceptance criterion is concrete: a caller can retrieve, rank, cite, and reuse only evidence currently authorized for that request. Retrieval quality can then be improved within that boundary.
Runnable isolated SQL fixture
This fixture passed on an isolated PostgreSQL 18 instance on October 6, 2026. The ordinary application role was used for protected reads. Its schema and role were rolled back and the disposable container removed. These checks do not measure vector retrieval quality or establish production security.
-- Run with psql -v ON_ERROR_STOP=1 -f rag-access-control-demo.sql
-- Use a disposable PostgreSQL database and a superuser for setup/teardown.
-- Protected reads are tested only after SET LOCAL ROLE app_runtime.
-- Requires the name app_runtime and schema rag_acl_demo to be unused.
-- All changes, including the role, are rolled back on success.
-- Identity GUCs are trusted SERVER context, not browser/client credentials.
-- Anyone with arbitrary SQL access as app_runtime can spoof these settings.
-- Real servers must verify identity/membership and bind values, e.g.:
-- SELECT set_config('rag.tenant_id', $1, true),
-- set_config('rag.principal_id', $2, true);
-- Use one transaction per request; never expose this role to untrusted clients.
BEGIN;
CREATE ROLE app_runtime NOLOGIN NOSUPERUSER NOBYPASSRLS;
CREATE SCHEMA rag_acl_demo;
CREATE TABLE rag_acl_demo.memberships (
tenant_id text NOT NULL,
principal_id text NOT NULL,
PRIMARY KEY (tenant_id, principal_id)
);
CREATE TABLE rag_acl_demo.document_grants (
tenant_id text NOT NULL,
document_id text NOT NULL,
principal_id text NOT NULL,
PRIMARY KEY (tenant_id, document_id, principal_id)
);
CREATE TABLE rag_acl_demo.chunks (
tenant_id text NOT NULL,
document_id text NOT NULL,
chunk_id text NOT NULL,
body text NOT NULL,
PRIMARY KEY (tenant_id, document_id, chunk_id)
);
INSERT INTO rag_acl_demo.memberships VALUES ('A', 'alice'), ('B', 'bob');
INSERT INTO rag_acl_demo.document_grants VALUES
('A', 'allowed', 'alice'), ('A', 'restricted', 'manager'),
('B', 'foreign', 'bob');
INSERT INTO rag_acl_demo.chunks VALUES
('A', 'allowed', '1', 'Generic permitted passage'),
('A', 'restricted', '1', 'Generic restricted passage'),
('B', 'foreign', '1', 'Generic foreign-tenant passage');
ALTER TABLE rag_acl_demo.memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_acl_demo.memberships FORCE ROW LEVEL SECURITY;
CREATE POLICY own_membership ON rag_acl_demo.memberships FOR SELECT TO app_runtime
USING (tenant_id = current_setting('rag.tenant_id', true)
AND principal_id = current_setting('rag.principal_id', true));
ALTER TABLE rag_acl_demo.document_grants ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_acl_demo.document_grants FORCE ROW LEVEL SECURITY;
CREATE POLICY own_grants ON rag_acl_demo.document_grants FOR SELECT TO app_runtime
USING (tenant_id = current_setting('rag.tenant_id', true)
AND principal_id = current_setting('rag.principal_id', true));
ALTER TABLE rag_acl_demo.chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_acl_demo.chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY authorized_chunks ON rag_acl_demo.chunks FOR SELECT TO app_runtime
USING (
tenant_id = current_setting('rag.tenant_id', true)
AND EXISTS (
SELECT 1 FROM rag_acl_demo.memberships m
WHERE m.tenant_id = chunks.tenant_id
AND m.principal_id = current_setting('rag.principal_id', true)
)
AND EXISTS (
SELECT 1 FROM rag_acl_demo.document_grants g
WHERE g.tenant_id = chunks.tenant_id
AND g.document_id = chunks.document_id
AND g.principal_id = current_setting('rag.principal_id', true)
)
);
GRANT USAGE ON SCHEMA rag_acl_demo TO app_runtime;
GRANT SELECT ON ALL TABLES IN SCHEMA rag_acl_demo TO app_runtime;
SET LOCAL ROLE app_runtime;
SELECT set_config('rag.tenant_id', '', true), set_config('rag.principal_id', '', true);
DO $$ BEGIN
IF current_user <> 'app_runtime' OR EXISTS (
SELECT 1 FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolbypassrls)
) THEN RAISE EXCEPTION 'Tests must use normal app_runtime role'; END IF;
IF EXISTS (SELECT 1 FROM rag_acl_demo.chunks)
THEN RAISE EXCEPTION 'Missing identity leaked content'; END IF;
END $$;
SELECT set_config('rag.tenant_id', 'A', true), set_config('rag.principal_id', 'alice', true);
DO $$ BEGIN
IF (SELECT count(*) FROM rag_acl_demo.chunks) <> 1
THEN RAISE EXCEPTION 'Expected exactly one authorized chunk'; END IF;
IF NOT EXISTS (SELECT 1 FROM rag_acl_demo.chunks WHERE document_id = 'allowed')
THEN RAISE EXCEPTION 'Positive authorization failed'; END IF;
IF EXISTS (SELECT 1 FROM rag_acl_demo.chunks WHERE tenant_id = 'B')
THEN RAISE EXCEPTION 'Foreign tenant leaked'; END IF;
IF EXISTS (SELECT 1 FROM rag_acl_demo.chunks WHERE document_id = 'restricted')
THEN RAISE EXCEPTION 'Document ACL leaked'; END IF;
END $$;
SELECT set_config('rag.tenant_id', 'B', true);
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM rag_acl_demo.chunks)
THEN RAISE EXCEPTION 'Forged tenant context granted access'; END IF;
END $$;
SELECT set_config('rag.tenant_id', 'A', true);
RESET ROLE;
-- Setup administrator performs revocation; app_runtime has no write grants.
DELETE FROM rag_acl_demo.document_grants
WHERE tenant_id = 'A' AND document_id = 'allowed' AND principal_id = 'alice';
SET LOCAL ROLE app_runtime;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM rag_acl_demo.chunks)
THEN RAISE EXCEPTION 'Permission revocation failed'; END IF;
RAISE NOTICE 'PASS: missing identity, tenant isolation, document ACL, revocation';
END $$;
RESET ROLE;
ROLLBACK;



Discussion
Comments are reviewed before publication. Your email is kept private.