A RAG assistant indexed with a service account can read everything. If retrieval does not filter by the asking user's permissions, the assistant becomes the easiest way to read the CFO's folder. Security trimming has to be designed in, not added later.
The model
Ingestion: document -> chunks -> each chunk carries allowed_groups / allowed_users / classification
Query: user identity -> group IDs -> filter(allowed_groups ∩ user_groups ≠ ∅) -> vector/hybrid search
The filter must be applied inside the search, before ranking, not afterwards in the application. Post-filtering returns fewer (or zero) results and is easy to get wrong.
Step 1: capture ACLs at ingestion
For each source, read effective permissions and resolve them to stable IDs (Entra object IDs, not display names):
| Source | Where the ACL comes from |
|---|---|
| SharePoint / OneDrive | Microsoft Graph permissions on the item (and inherited site/library permissions) |
| File shares (SMB) | NTFS ACLs (SIDs), mapped to Entra objects if synced |
| Confluence / wikis | Space and page restrictions via API |
| Ticket systems | Project / queue membership |
| Databases | Row ownership columns |
Store on every chunk:
{
"id": "hr-policy-2026.pdf#12",
"content": "...",
"embedding": [ ... ],
"allowed_groups": ["6f1c...-hr-team", "a92b...-all-managers"],
"allowed_users": [],
"classification": "confidential",
"source_url": "https://contoso.sharepoint.com/...",
"acl_synced_at": "2026-10-29T06:00:00Z"
}
Warning
"Everyone except" style permissions (deny entries) do not map cleanly to allow-list filters. Either resolve them at ingestion into explicit allowed principals, or do not index those documents.
Step 2: index fields as filterable
Azure AI Search field definition:
{ "name": "allowed_groups", "type": "Collection(Edm.String)", "filterable": true, "retrievable": false }
pgvector:
ALTER TABLE chunks ADD COLUMN allowed_groups uuid[] NOT NULL DEFAULT '{}';
CREATE INDEX chunks_allowed_groups_gin ON chunks USING gin (allowed_groups);
Qdrant: create a payload index on allowed_groups (keyword type) so filtering is efficient.
Step 3: filter at query time from the token, not the request
Get the user's groups from the validated access token (or a server-side Graph lookup), never from a parameter the client sends.
def user_groups(token_claims) -> list[str]:
groups = token_claims.get("groups", [])
if token_claims.get("_claim_names", {}).get("groups"): # group overage: too many groups for the token
groups = graph_member_of(token_claims["oid"]) # transitive membership via Graph
return groups
Azure AI Search:
groups = user_groups(claims)
flt = "allowed_groups/any(g: search.in(g, '{}'))".format(",".join(groups))
results = search_client.search(search_text=q, vector_queries=[vq], filter=flt, top=8)
pgvector:
SELECT id, content
FROM chunks
WHERE allowed_groups && $1::uuid[] -- overlap with user's groups
ORDER BY embedding <=> $2
LIMIT 8;
With HNSW indexes, restrictive filters can return fewer than LIMIT rows; on pgvector 0.8+ enable iterative scans (SET hnsw.iterative_scan = relaxed_order;). In Qdrant and Azure AI Search, filtered vector search is applied during the search.
Azure AI Search also has built-in document-level access control features for some sources (for example permission filtering with Entra tokens in recent API versions). Check current documentation; when available, it reduces custom code.
Step 4: keep permissions fresh
Permissions change more often than content. Two sync loops:
| Loop | Frequency | What |
|---|---|---|
| Content | On change | New and changed documents, re-chunk and embed |
| ACL | Frequent (minutes to an hour) | Update allowed_groups on existing chunks without re-embedding |
Track acl_synced_at and alert when a source has not synced within its window. When a document is deleted or a user is removed from a group, the next query must reflect it; group membership changes are picked up at query time automatically because you filter on current token groups.
Step 5: protect the rest of the pipeline
- Caches: a semantic or response cache keyed only by question leaks across users. Include a hash of the user's group set in the cache key, or cache retrieval results per permission set.
- Logs and traces: retrieved chunks in traces are as sensitive as the documents. Restrict trace access.
- Citations: only show links the user can open (they can, if trimming is right, which doubles as a check).
- Agents and tools: tools that fetch documents must use the user's identity (on-behalf-of), not the service account.
Step 6: test for leaks
Create test users in groups with known access, and documents with known permissions:
CASES = [
("user_hr", "What is the 2026 salary band for L5?", "must_find", "hr-salary-bands-2026"),
("user_eng", "What is the 2026 salary band for L5?", "must_not_find", "hr-salary-bands-2026"),
]
for user, q, expect, doc in CASES:
got = {r["doc"] for r in retrieve_as(user, q, k=20)}
assert (doc in got) == (expect == "must_find"), (user, q)
Run them in CI and after every ACL sync change. Also test the answer, not just retrieval: the model should say it has no information, not hint at restricted content.
Caution
Never test leakage only with admin accounts. Admins can read everything, so every test passes.
Prevent it next time
- No source goes into the index without an ACL mapping and an owner.
- ACL sync monitored with an alert on staleness.
- Leak test suite in CI with representative users.
- Periodic access review of what the RAG service account can read; remove what it does not need.