- name
- agentsop-multi-tenant-rag
- version
- 0.1.0
- description
- Security-first SOP for multi-tenant RAG systems. Activate when a calling agent
is building, reviewing, or debugging any retrieval pipeline whose vector store
is shared across more than one user, organisation, workspace, customer, or
permission scope. Encodes the single non-negotiable rule — **filter at the
vector store query, never after retrieval / never after rerank** — together
with the per-vendor query-time filter APIs (Pinecone namespaces +
`$eq`/`$in`, Weaviate `multiTenancyConfig` + tenant handle, Qdrant
`is_tenant` payload index + `Filter.must`, Chroma `where`, pgvector RLS),
and the cross-framework adapters (LlamaIndex `MetadataFilters`, LangChain
`filter=` dict). Frame the work as preventing CVE-2024-41892 / EchoLeak /
Slack-AI-class cross-tenant leakage, not as "adding a filter for relevance".
- trigger_keywords
- ["multi-tenant RAG","tenant isolation","metadata filter","namespace per tenant","multitenancy vector store","RLS pgvector","cross-tenant leak","RAG security","workspace separation"]
- when_to_use
- ["any RAG / retrieval pipeline whose corpus is shared across >1 tenant, customer, org, user, or permission scope","reviewing PRs that touch `vector_store.query` / `index.as_retriever` / `similarity_search` / `vectorstore.query_points`","designing ingestion for a SaaS knowledge base, internal corpus split by team, or per-customer corpus","responding to a 'we got the wrong document' bug — confirm it isn't actually a cross-tenant leak first","before shipping any RAG endpoint that exposes retrieved content to end users"]
- when_not_to_use
- ["single-tenant / single-user corpora with no access scopes — over-engineering","purely internal eval / offline notebooks against fully public data","the retrieval boundary is enforced by a per-tenant collection / index that the application can never address by name — already isolated"]
# Multi-Tenant RAG · Security-First Isolation SOP
> Third-person operating model for a coder agent that owns retrieval correctness
> across tenant boundaries. The audience is the LLM agent writing or reviewing
> the code — not the end user.
> **One sentence**: *Isolation lives at the vector store query boundary, not
> at the model. Anything that reaches the LLM's context window has already
> leaked.*
---
## 1. 何时激活 (Activation Rules)
Activate this skill whenever **any** of the following holds:
1. The codebase contains a retrieval call (`vector_store.query`,
`query_points`, `similarity_search`, `as_retriever().retrieve(...)`, raw
`pgvector` `ORDER BY embedding <-> $1`) **and** the corpus serves more than
one tenant, customer, organisation, workspace, user, or permission scope.
2. The user mentions any of: multi-tenant RAG, namespace, tenant, workspace,
`tenant_id`, `org_id`, `user_id`, "cross-customer", "shared index",
"knowledge base per team".
3. A bug report says "User A saw User B's document", "wrong company's data
surfaced", "the assistant cited a doc I don't have access to", or anything
that smells like cross-context bleed.
4. PR review: any new code calling a vector store **without** a tenant-scoped
filter argument, or filtering only on the returned `nodes` / `documents`
list after retrieval.
5. A new RAG endpoint is about to ship and tenant scoping has not been
explicitly audited.
Do **not** activate when:
- The corpus is fully public and there is no per-tenant view (e.g. open-data
Q&A).
- The retrieval pipeline already enforces a **physically separate** index /
collection per tenant via infrastructure the application code cannot
override (e.g. one Pinecone index per customer with credentials issued
per-tenant). In that case the isolation lives in IAM, not in this skill.
---
## 2. 核心心智模型 (Core Mental Model)
Three principles. If a design violates any of them the system is exploitable,
regardless of how good the LLM prompt is.
### Principle 1 — Isolation lives at the boundary, not at the model
The boundary is the vector store query call. **Whatever crosses that boundary
is in the trust set.** An LLM, a reranker, or a postprocessor that "filters
out" foreign-tenant chunks is operating *inside* the breach: those tokens have
already been embedded, retrieved, scored, and exposed to attacker-controlled
prompts. CVE-2024-41892 (Pinecone, 2024) is the canonical demonstration —
RBAC checks executed *after* retrieval allowed sentinel data to cross
namespace boundaries before the access check fired. (See *we45*, *CSO Online*
references.)
> **Operational corollary**: any code shaped `results = vs.query(...); results
> = [r for r in results if r.metadata["tenant"] == ctx.tenant]` is a security
> defect, not a style nit. The leak already happened — you only hid it from
> the user.
### Principle 2 — Embed the tenant key at ingestion, not just at query
A query-time filter is only enforceable if every vector carries the key. The
two failure shapes:
- *Forgotten field*: a single document ingested without `tenant_id` becomes a
shared global record returned to all tenants — it matches no filter
predicate that requires equality.
- *Mutable field*: `tenant_id` derived from the document body / LLM extraction
rather than from the request's authenticated session — attacker can craft
document content that re-labels itself.
The tenant key **must** come from the authenticated session at ingestion time
and be written into the immutable payload / metadata column. Treat it like a
foreign key to your tenants table.
### Principle 3 — Defence in depth: namespace **and** filter, not namespace **or** filter
Most production-grade stacks layer two mechanisms:
| Layer | Mechanism | What it stops |
|---|---|---|
| Storage | Per-tenant namespace / shard / collection / RLS policy | Operator bugs, mis-routed queries, ops mistakes |
| Query | `filter={tenant_id: $session.tenant}` on every call | App bugs, namespace selection mistakes, cross-shard joins |
Pinecone's official guidance is "namespace per tenant in serverless"; Weaviate
mandates a tenant handle on every CRUD when `multiTenancyConfig.enabled=true`;
Qdrant marks the field as `is_tenant=true` *and* requires `Filter.must` on
read; pgvector pairs schema partitioning with RLS. The principle is the same:
one layer is one bug away from failing open.
---
## 3. SOP 工作流 (Agentic Protocol)
Eight stages. Each gates the next. Stop and remediate at the first failure.
### Stage 0 — Threat-model the boundary
Before any code change, write down:
1. **Tenant key**: what identifier separates customers? (`tenant_id`,
`org_id`, `workspace_id`, `user_id`, `acl_label`). Pick one — do not mix.
2. **Trust source**: where does the request-time tenant key come from?
It must be from the authenticated session / JWT claim / signed header —
**never from a query param, prompt, or document body**.
3. **Cardinality**: how many tenants? (<100 → namespace/collection viable;
>10k → payload partition + index is mandatory). Drives Stage 2 choice.
4. **Blast radius**: if a single vector leaks, what's the worst document?
(PII / regulated / IP / public). Raises bar on Stages 4-6.
Artifact: a 5-line `THREAT_MODEL.md` snippet co-located with the retriever.
### Stage 1 — Pick the isolation primitive (vendor-specific)
| Vendor | Recommended primitive | When |
|---|---|---|
| **Pinecone** | One **namespace** per tenant in a serverless index | Default; cheapest query (1 RU per 1 GB / namespace); offboarding = `delete_namespace` |
| **Weaviate** | `multiTenancyConfig.enabled=True` on the collection | Native, per-shard isolation; mandatory tenant handle on CRUD |
| **Qdrant** | Single collection, payload key with `is_tenant=true`, `keyword` index | Scales to millions of tenants; physical co-location, logical isolation |
| **Chroma** | `where={"tenant_id": "..."}` on every `query`/`get`/`update`/`delete` | Smallest stacks; cookbook calls this the "naive multi-tenancy strategy" — accept its limits |
| **pgvector** | Postgres schema or row-level security (RLS) on `tenant_id` column | When the rest of the app already lives in Postgres |
| **Milvus** | `partition_key` on tenant field | Same shape as Qdrant `is_tenant` |
Decision rule: **prefer the strongest physical primitive your tenant
cardinality can afford**, then always add the filter at query time anyway
(Principle 3).
### Stage 2 — Ingestion: write the key, lock the schema
```python
# Canonical shape — vendor-agnostic
node.metadata = {
"tenant_id": session.tenant_id, # MUST come from authenticated session
"doc_id": doc.id,
"source": doc.uri,
"ingested_at": now_utc(),
# ... domain fields
}
assert node.metadata["tenant_id"], "refuse to embed without tenant_id"
```
Hard rules:
- Refuse to write a vector if `tenant_id` is missing or empty — fail loud, do
not default.
- `tenant_id` is **never** taken from document content; **never** mutable
post-ingestion.
- Schema-validate metadata before embedding (Pydantic / JSON Schema) — type
drift breaks `$eq` silently.
- For Qdrant: create the payload index *before* the first upsert
(`create_payload_index(field="tenant_id", schema="keyword",
is_tenant=True)`).
- For Weaviate: create the tenant (`collection.tenants.create([Tenant(name=t)])`)
before any write.
### Stage 3 — Query-time filter (the only place the rule applies)
**Every** retrieval call must accept a tenant key from the session and pass
it into the vendor's filter argument. Templates in OP-04 through OP-08.
```python
# Generic LlamaIndex shape — works across Pinecone, Qdrant, Weaviate, Chroma
from llama_index.core.vector_stores import (
MetadataFilter, MetadataFilters, FilterOperator,
)
filters = MetadataFilters(filters=[
MetadataFilter(key="tenant_id", value=session.tenant_id,
operator=FilterOperator.EQ),
])
retriever = index.as_retriever(similarity_top_k=8, filters=filters)
```
If the framework or vendor SDK does not expose a query-time filter, **switch
the framework or vendor — do not patch with a post-filter**.
### Stage 4 — Cross-tenant property test (the gate)
Before merging, the PR must include — and CI must run — a test that:
1. Ingests N≥3 documents per tenant for at least 2 tenants (A and B).
2. Issues a query as tenant A whose semantic top-1 in the *unfiltered* search
would be a tenant-B document (craft the query against B's content).
3. Asserts zero tenant-B results returned.
4. Repeats symmetrically as tenant B.
If the unfiltered top-1 is *not* a foreign-tenant doc, the test is dishonest
— rewrite the corpus to make foreign-tenant semantically closer, otherwise
the test always passes vacuously.
See OP-09 for a runnable scaffold.
### Stage 5 — Defense in depth (Principle 3)
Layer at least one of:
- Per-tenant namespace / shard / collection on top of the metadata filter.
- Per-tenant API key / scoped credential at the vector store layer (Pinecone
API key project scope, Postgres role per tenant).
- DB-level enforcement (pgvector RLS policy `USING (tenant_id =
current_setting('app.current_tenant')::uuid)`).
A single layer is one config bug away from open.
### Stage 6 — Audit logging
Every retrieval call logs:
```
{ts, request_id, session.tenant_id, query_hash,
vs.namespace_or_collection, filter_clause,
returned_count, returned_tenant_ids_set}
```
Then add a synchronous assertion in the request path:
```python
assert returned_tenant_ids_set.issubset({session.tenant_id, GLOBAL_TENANT})
```
This converts a silent leak into a loud 500 — the right failure mode.
### Stage 7 — Cache / rerank / postprocessing audit
Walk every component **after** retrieval and confirm none of them:
- Caches results across tenants under a tenant-agnostic key (e.g.
`cache_key = hash(query)` is a leak; correct is `cache_key = hash((tenant_id,
query))`).
- Returns "fallback" results from a global pool when the tenant pool is
empty.
- Logs full retrieved content to a shared monitoring store readable by other
tenants' staff.
Reranker / NodePostprocessor / synthesizer can only safely *narrow* the set;
they never restore foreign tenants and never invent context — confirm by
reading the code path.
### Stage 8 — Ongoing verification
- Add the cross-tenant property test to nightly CI, not just merge gate.
- On schema migration: re-run with the new chunking / embedding model.
- On vendor upgrade: re-read the changelog for filter / namespace semantics
(this changes more than vendors admit — see Pinecone disk-based metadata
filtering, Qdrant 1.16 tiered multitenancy).
---
## 4. 操作模型 (Operation Models)
Format: **Trigger / Action / Output / Evidence**. Vendor-specific filter
syntax canonicalised against current docs (May 2026).
### OP-01 EmbedTenantAtIngest
- **Trigger**: New ingestion pipeline; any system not currently writing
`tenant_id` into metadata.
- **Action**: Add `tenant_id` (from authenticated session) to every node's
metadata at chunk creation. Validate non-empty before `add()` /
`upsert()`. Make the field part of the ingestion schema.
- **Output**: 100% of vectors carry the immutable tenant key; ingestion
refuses to write otherwise.
- **Evidence**: Qdrant *Multitenancy* docs; Pinecone *Implement multitenancy*
guide; we45 RAG leakage post-mortem.
### OP-02 ChooseIsolationPrimitive
- **Trigger**: Greenfield RAG, or migration from single-tenant.
- **Action**: Use the cardinality / latency / cost matrix in Stage 1. Default
ladder: <100 tenants → namespace/collection per tenant; 100-10k → payload
partition + index (`is_tenant`); >10k → tiered (hot collection +
cold archive). Pair with query filter (Principle 3).
- **Output**: A documented primitive + the one-line justification.
- **Evidence**: Pinecone *Namespaces vs. metadata filtering*; Qdrant
*Multitenancy and custom sharding*; Weaviate *Multi-tenancy operations*.
### OP-03 LockTenantSourceToSession
- **Trigger**: Code review reveals `tenant_id = request.json["tenant_id"]`
or similar untrusted source.
- **Action**: Bind `tenant_id` to the authenticated principal (JWT claim,
session row, signed header). Pass through one chokepoint (`Context` /
`RequestState`) — never read user input again downstream.
- **Output**: A single `get_tenant_id(ctx)` accessor used everywhere; no
string-typed tenant ids floating through function args.
- **Evidence**: OWASP LLM01 (prompt injection) + LLM06 (sensitive info
disclosure); Christian Schneider *RAG security: the forgotten attack
surface*.
### OP-04 PineconeFilterAtQuery
- **Trigger**: Pinecone-backed RAG; multi-tenant.
- **Action**: One namespace per tenant; **and** pass `filter={"tenant_id":
{"$eq": tenant_id}}`. Avoid `$in` lists >10,000 (hard cap).
```python
index.query(
namespace=tenant_id, # primary isolation
vector=embedding,
top_k=8,
filter={"tenant_id": {"$eq": tenant_id}}, # defence-in-depth
include_metadata=True,
)
```
- **Output**: Two-layer isolation; `delete_namespace(tenant_id)` for
offboarding.
- **Evidence**: `docs.pinecone.io/guides/index-data/implement-multitenancy`,
`docs.pinecone.io/troubleshooting/namespaces-vs-metadata-filtering`.
### OP-05 WeaviateTenantHandle
- **Trigger**: Weaviate-backed RAG.
- **Action**: Enable `multiTenancyConfig(enabled=True)` on the collection;
create one tenant per customer; pass `tenant=` on every read/write.
```python
coll = client.collections.get("Docs").with_tenant(session.tenant_id)
coll.query.near_vector(vector=emb, limit=8)
```
Optionally `auto_tenant_activation=True` if tenant set is sparse.
- **Output**: Each tenant on a separate shard; cross-tenant reads physically
impossible from a single client handle.
- **Evidence**: `docs.weaviate.io/weaviate/manage-collections/multi-tenancy`;
*Rethinking Vector Search at Scale* (Weaviate blog).
### OP-06 QdrantIsTenantPayload
- **Trigger**: Qdrant-backed RAG with ≥hundreds of tenants.
- **Action**: Single collection; create payload index with `is_tenant=True`;
filter on every search.
```python
client.create_payload_index(
collection_name="docs",
field_name="tenant_id",
field_schema=models.KeywordIndexParams(
type="keyword", is_tenant=True))
client.query_points(
collection_name="docs",
query=emb, limit=8,
query_filter=models.Filter(must=[
models.FieldCondition(
key="tenant_id",
match=models.MatchValue(value=session.tenant_id))]),
)
```
- **Output**: Per-tenant sub-indexes co-located on shared shards; scales to
millions of tenants.
- **Evidence**: `qdrant.tech/documentation/manage-data/multitenancy/`;
`qdrant.tech/articles/multitenancy/`;
`qdrant.tech/documentation/examples/llama-index-multitenancy/`.
### OP-07 ChromaWhereFilter
- **Trigger**: Chroma-backed RAG (often local / smaller scale).
- **Action**: Filter on every call.
```python
collection.query(
Auf GitHub ansehen