Multi-Tenant RAG Access Control · Bedrock

Multi-Tenant RAG with Access Control

A retrieval assistant where who you are decides what you can retrieve — and the model never gets a vote. Tenancy and document-level permissions are enforced server-side from a verified identity, so two identical questions return two different correct answers, and one tenant can never see another's data.

Verified identity Tenant gate Group ACL Grounded answer
Server-side tenancy One collection per tenant access_groups ACL CI leakage test
127.0.0.1:8300 · Chat — acme-sales @ Acme Corp

Chat

Acme Corp qwen.qwen3-235b-a22b 3 chunks
How much does your cloud product cost per month?
Agent trajectory
KB Knowledge Base “Acme Cloud price per user per month” 2 passages 🔒 1 hidden
Acme Cloud Pro is priced at $49 per user per month, billed annually; the Starter plan is $19 per user per month 1.
Sources — all within your access
1 public acme-cloud-pricing.md sim 0.62
all tenant members
Acme Cloud Pro is priced at $49 per user per month, billed annually. The Starter plan is $19 per user per month. These are the current, publicly listed prices…

The Chat view — tenant-scoped retrieval, cited answers, and the access layer reporting how many passages it 🔒 hid (other tenant / wrong group). The confidential Q3 pricing is the “1 hidden” here.

What it is

Isolation is who you are, not what you ask

Several tenants share one app and one model. Each user logs in and receives a verified principal — a tenant plus a set of access groups. Every search is scoped to that principal server-side: the model can drive retrieval, but it can never choose whose data it reaches. The result is the security-critical pattern for multi-tenant RAG, made concrete and testable.

Server-side tenancy

The tenant comes from the verified session token, never the request body or the model. There is deliberately no tenant field on the chat or ingest API.

One collection per tenant

Each tenant's vectors live in a physically separate collection (index/<tenant>/), so a cross-tenant hit is impossible — not merely filtered.

Document-level access_groups

Within a tenant, each document carries an access-group list. A user retrieves it only if their verified groups intersect — that's how unreleased pricing stays with the pricing team.

Two gates, defense in depth

One choke point applies a tenant gate and a group gate. Every chunk is stamped with its tenant, so even a packing bug degrades to “no results,” never a leak.

The model can't widen scope

The search_knowledge_base tool exposes only a query. A prompt-injected model that smuggles tenant=acme into its call is simply ignored — there's a test for exactly that.

Automated leakage test (CI)

Two identical questions → two different correct answers, and Globex can never retrieve Acme's unreleased pricing — proven deterministically on every push, no model or network needed.

Verified principals & sessions

PBKDF2-hashed credentials, per-user bearer sessions, and a directory of tenants, groups and roles. Threads are per-user; you never see another user's conversations.

Streamed, cited answers

NDJSON streaming renders the agent trajectory live — including a “🔒 N hidden” pill showing how many candidates the ACL removed — with grounded [n] citations.

Works with no credentials

No Bedrock key? Chat falls back to a deterministic grounded answer over your accessible documents. The access decision is identical — it happens before any model runs.

Why it's useful

The shape of every serious RAG deployment

The moment more than one customer, team, or clearance level shares a knowledge base, “retrieve the relevant passages” becomes “retrieve the relevant passages this caller is allowed to see.” Getting that wrong is a data breach; this is the pattern that gets it right.

SaaS knowledge assistants

One deployment serving many customer organizations, each strictly walled off from the others' documents and chat history.

Team-level confidentiality

Unreleased pricing, HR files, board decks — visible to the right group inside a company, invisible to everyone else, from the same assistant.

Regulated data rooms

M&A diligence, legal, healthcare — where “who could this content have reached” must be answerable and provable.

Per-account support copilots

A support agent's assistant that answers only from the customer's own tickets, contracts and configuration.

Enterprise internal search

One search box over many departments' wikis, where results honor each employee's group memberships automatically.

Tech stack

What it's built on

A small, dependency-light Python backend and a vanilla-JS frontend with no build step. The security boundary is a single, auditable function.

Access control
Verified principalsTenant gateGroup ACLone choke pointdefense in depth
Agent / inference
Amazon BedrockAnthropic MantleClaude (tool use)Qwen (OpenAI-compat)offline fallback
Backend
FastAPIUvicornPydanticNDJSON streaming
Retrieval
sentence-transformerscosine similarityper-tenant collectionsaccess_groups metadata
Storage & auth
SQLitePBKDF2bearer sessionsper-user threads
Testing / CI
pytesthash embedder (hermetic)GitHub Actionsno torch, no network
How it works

Identity in, filtered passages out

The bearer token resolves to a principal; retrieval flows through one function that applies both gates. Grep the server for principal.tenant_id — that value only ever comes from the token.

bearer token ─▶ Principal { tenant, groups }        (verified — never the body, never the model)
                    │
   user turn ─▶ model ──search?──▶ access.retrieve(principal, query)
                                        │  tenant gate  → index/<tenant>/   (physical split)
                                        │  group gate   → access_groups ∩ groups
                                        ▼
                              visible passages ─▶ grounded, cited answer

Authenticate → a verified principal

A user signs in; the server verifies the password (PBKDF2) and mints a bearer session that resolves to a Principal{ tenant_id, groups, role }. This is the single source of tenancy for the rest of the request.

The model decides to search — with only a query

The agent hands the model one tool, search_knowledge_base(query). The tool schema has no tenant, no groups. The executor reads args["query"] and nothing else, so any injected scope argument is inert.

Tenant gate — the collection is chosen for you

Retrieval loads the collection at index/<principal.tenant_id>/. With one collection per tenant, another tenant's vectors aren't candidates at all. A defensive check also drops any hit whose stamped tenant disagrees.

Group gate — document-level permissions

Each candidate carries the document's access_groups. A document with none is visible to every member of the tenant; otherwise the caller must hold one of the listed groups. The predicate is pure and total — easy to test, hard to bypass.

Ground, cite & stream — or answer offline

Visible passages are numbered and cited [n]; the answer streams token by token with the trajectory, including how many candidates were hidden. With no model configured, a deterministic grounded answer over the same filtered passages takes over.

Prove it — the CI leakage test

A pytest suite runs the exact retrieval path over the demo corpus with a deterministic hash embedder: two identical questions yield two different correct answers, a battery of adversarial Globex queries (including a prompt-injected tenant=acme) return zero Acme content, and the group gate holds within a tenant.

⚡ Two identical questions, two different correct answers — $49 for Acme, $59 for Globex — and a CI test proving Globex can never see Acme's unreleased $29 pricing.
Get started

Run it in two commands

Ships with a pre-seeded app_config.db, so the Bedrock region, key and model carry over — and it seeds two demo tenants, five users and a small corpus on first run.

# install & launch
pip install -r requirements.txt
python server.py                 # → http://127.0.0.1:8300  ·  overview at /overview

# then, in the browser:
1. Sign in as acme-sales / demo1234  → ask “how much does your cloud product cost per month?”
2. Sign out, sign in as globex-sales → same question, different answer
3. Settings → model & Bedrock connection (password: bala@143)

# prove the isolation (hermetic — just numpy + pytest)
pip install -r requirements-ci.txt
pytest tests/ -v                 # the leakage test