Short version: a RAG system that respects permissions enforces them at retrieval, on every chunk, on every cached answer, and on every copy of the corpus your observability stack quietly made. Telling the model to withhold a document is a request it can decline to honour, the same fail-open shape described in stopping Kubernetes admission control failing open. Each tip below names the field, header or setting doing the enforcing, plus the test that produces evidence an auditor will accept.
Three separate controls get collapsed into one sentence in most guidance: index-level isolation, retrieval-time filtering, and generation-time instruction. They fail in different ways and at different times. The version behaviour also moved recently. Azure AI Search ships ACL ingestion under the 2026-08-01-preview API, pgvector only fixed filtered-scan under-return in 0.8.0, and two of the sources here were published in June and September 2026. This is for whoever has to answer one question from a regulator, a security questionnaire or an internal access review: can user B retrieve user A's document, and what do you hand over to show it?
The tips
- Write the leak test against the candidate set, before any model call. A test that grades the model's reply passes whenever the model politely declines to quote the document it was just handed, which tells you nothing about what left the index. Run the same query as principal A and principal B and assert that A's document ID never appears in B's retrieval result. No model call, no flakiness, and a failure that points at one component.
- Send the caller's token to the index rather than mapping it to a role in your app. Azure AI Search compares Entra claims against per-document permission metadata when you mark a string field with the
permissionFilterattribute and attach the user's token in thex-ms-query-source-authorizationheader. Microsoft's document-level access overview is explicit that two checks run: the app still needs Search Index Data Reader, and the extra token trims documents the principal cannot read. ACL ingestion is preview under2026-08-01-preview, while the plain string security filter is generally available and API agnostic. The same identity-propagation argument runs through killing static ServiceAccount tokens: a short-lived credential per caller beats one shared credential the app reasons about on the caller's behalf. - Carry the ACL field through your index projections when you chunk. Microsoft's page states that when a skillset chunks documents, permission metadata moves from indexer field mappings to index projections, and that without that projection, chunk-level references are not filtered. The parent document gets trimmed correctly while the 400-token chunk holding the sensitive sentence does not. Grep your skillset for a projection that carries the permission field, then run tip 1 against the chunk index specifically rather than the parent index.
- Derive the filter from the session principal and reject any filter the model supplies. If your retrieval tool exposes a metadata filter as a callable argument, that filter is attacker-writable through indirect injection: poisoned content in one retrieved chunk can talk the orchestrator into widening scope on the next call. Resolve the filter server-side, log the principal and the resolved filter as one record, and refuse tool calls that carry a filter argument at all. Tool surfaces have their own version of this problem, covered in proving MCP tool permissions, where a server's self-declared
readOnlyHintcarries no enforcement behind it. - Expect a filtered vector search to return fewer rows than you asked for. pgvector's README documents that filtering is applied after the index scan, so a condition matching 10% of rows with the default
hnsw.ef_searchof 40 yields roughly 4 matches on average. Teams hit this as "search got worse once we added permissions" and repair it by raisingkor removing the filter, which is the wrong repair. Use iterative scan, added in 0.8.0:
SET LOCAL hnsw.iterative_scan = strict_order;
SET LOCAL hnsw.max_scan_tuples = 20000;
- Enforce in the database, not in the query builder. Supabase's RAG-with-permissions guide recommends row level security on the sections table over a
whereclause, because RLS keeps applying as new queries and new application code appear later:
create policy "Users can query their own document sections"
on document_sections for select to authenticated using (document_id in (select id from documents where owner_id = auth.uid()));
The operational catch: a worker connecting through a pooled service role bypasses RLS completely, so the retrieval path has to run under the end user's JWT.
- Write your revocation SLA down in wall-clock minutes. Query-time enforcement compares the caller's claims against permission metadata already sitting in the index. Microsoft states that source-system changes appear in results only after a subsequent indexer run, push-API update or Purview refresh, and that for SharePoint, permissions inherited from a parent site, library, list or folder need an explicit refresh even where unique-permission changes are picked up incrementally. Revoking access at 09:00 does not revoke retrieval until that sync finishes. Measure the interval, publish it, alert when it slips.
- Treat a deleted document as present until the index is compacted. The Ghost Vectors paper (Chakraborttii et al., arXiv 2606.18497, June 2026) recovers soft-deleted embeddings straight from raw HNSW index files, bypassing API-level protections. Their reported recovery: 25.5% of exact names and 46.4% of geographic locations on a Wikipedia biographical dataset, 100% for patient age and gender on NIH Synthea, 99% identity recovery on facial embeddings. Their mitigation is epoch key rotation, encrypting vectors and discarding the key on delete, taking PII recovery to 0% at about 0.005 ms per record. Until you run something like that, schedule real compaction and classify index snapshots as containing deleted data.
- Audit the semantic cache as a second retrieval path. Issue #2134 on vllm-project/semantic-router documents a cross-tenant response leak filed as CWE-524. User scoping worked by prepending an HMAC namespace token three times to the query text, so on longer queries the 16-character prefix barely shifted the embedding, and the reproduction shows bob receiving alice's byte-identical cached answer at similarity 0.9085 against a 0.8 threshold. The fix adds an exact namespace comparison for the in-memory backend, with Redis, Milvus, Qdrant and Valkey listed as follow-up, so check which backend you actually run. Wu et al. report the matching poisoning result across Azure, AWS and Alibaba serving stacks in their NDSS 2026 paper.
- Count traces, typeahead and eval sets as copies of the corpus. Retrieved chunk text lands in your tracing tool under that tool's ACLs and that tool's retention, neither of which you configured. Autocomplete is a separate surface again: Microsoft notes the Autocomplete and Suggest APIs are unavailable for Purview-enabled indexes, a clean signal that typeahead does not inherit the filter you set on search. Inventory every store holding chunk text and give each one a named owner.
- Plant a canary per principal group and report recall@k per group. Insert one document per group that nothing else should ever retrieve, then run a nightly job asserting each canary stays invisible to every other group. That job's output becomes your access-review evidence. Track recall@k per group in the same run, because a tightened filter and a broken filter look identical in a global average, and "it used to find that file" arrives as a support ticket instead of an alert. If your control mapping still cites bare OWASP LLM identifiers, recheck them against the 2026 crosswalk renumbering.
Wrap-up
Make the negative case executable. A per-principal canary suite that fails the build outlives the person who designed the filter, which no architecture diagram does.
Scoping the review honestly: enumerate every copy of the corpus, prove the chunk-level filter, time the revocation path end to end, and leave the canary suite and the recall report behind as standing evidence. For a single retrieval stack that runs two to three weeks, separate from remediating whatever it turns up. Start with tip 3. If the chunk carries no ACL, the other ten are decoration.
Comments
Be the first to comment.