The leak
Filter after, and it leaks
Same question, same corpus, one switch. Watch what rides into the answer when the filter runs after retrieval instead of inside it.
- Filter at retrieval (ON). The query itself excludes anything the caller can’t see. The model only ever receives passages it’s allowed to read.
- Filter after (OFF). The model retrieves everything, then is told to hide the restricted parts. It doesn’t reliably obey — and the restricted text is already in its context.
- Two ways to leak. A role leak (the underwriting memo reaching a broker) and a cross-tenant leak (another insurer’s claim reaching you).
- The point. “The model saw it but chose not to say it” is not access control. Not-retrieved is the only safe state.
↳ You’re the Acme broker. Leave the filter off and read what reaches the model — then switch it on.
Brokers see policies only — no claims, no memos.
With the filter off, the broker’s answer quotes an underwriting risk memo and a claim from another insurer — because the model saw both, and “please hide them” is not a security control. With the filter on, those documents are never retrieved, so they can’t leak. The safe answer and the leaky answer differ by one thing: where the check runs.
Provenance
The filter is built from the token, never the prompt
The permission check is compiled from the caller’s signed identity — tenant, environment and roles — resolved from a verified token (a JWT in production). The question the user typed can never widen access.
- Signed token, not text. Tenant, env and roles come from verified claims. The question is data to answer, never a source of permissions.
- Identity → RetrievalFilter. The token resolves to an
Identity, andbuild_filterturns it into the exactRetrievalFilterpushed into the search. - Three conditions, all required. Scope match (tenant and env), role overlap (
chunk.acl_roles ∩ roles ≠ ∅), and an optional doc-type narrowing. - Fail closed. No roles → the filter matches nothing. The default is deny, never allow — an empty role list must not become an open door.
If access came from the request body or the question text, a user could type themselves into the underwriter role — it’s forgeable, because the user controls it. Deriving the filter only from verified token claims is what closes that hole. This is the single bug the whole post exists to prevent.
build_filter fails closed on empty roles; is_visible is the one definition of “allowed” both stores share — the local store calls it directly, and the production store encodes the same scope + role test as a boolean filter query:
And the retrieve node uses it in exactly one place — the filter is derived from the identity, then pushed into the search call, not applied to the results afterward:
Isolation
Tenant and env are partition keys, not afterthoughts
The same scope check that stops role leaks stops one insurer from ever seeing another — and dev from ever leaking into prod. Every document, chunk and query carries a TenantScope (tenant + env), and the vector index is namespaced tenant__env.
- Namespace =
tenant__env. The index is partitioned by it, soacme__prodandfjord__proddon’t share storage — a query for one can’t reach the other. - Scope is a mandatory filter. Retrieval always filters on scope — no scope, no results. There is no “search across everything” path to get wrong.
- Envs are isolated too.
dev,qaandprodare separate scopes. A test document indevcan never surface in aprodanswer. - Deletes really delete. When the data lake sends a delete tombstone, the document leaves the index for that scope — a redacted claim stops being retrievable, the governance story that matters in insurance.
- Multi-tenant isolation is non-negotiable. A cloud RAG system serves many insurers across environments (dev/qa/prod). One insurer seeing another’s claim isn’t a bug, it’s a breach. Making
tenant__envthe index partition key — so a query can’t cross it — is the honest guarantee, not aWHEREclause you hope nobody forgets. - CCPA / GDPR need auditability and deletion. “Who accessed what, when, over which tenant” has to be reconstructable — hence one append-only audit line per query. And when a record is redacted, the delete tombstone removes the document from retrieval, so a right-to-be-forgotten request actually takes effect.
- Least privilege at the data layer, not the UI. An adjuster, an underwriter and a broker see different things by role — enforced where the data is fetched, not by trusting a prompt or hiding a button.
Everything above is one stage of the repo. Check out this tag and the access filter is wired into the retrieve node — no cloud required, it runs against the local in-memory store. Ask the same question as two roles and watch the answer change with access, not with the prompt.
The broker’s answer is grounded in the policy alone; the underwriter’s cites the risk memo too. Nothing about the question changed — only the signed role did, and the retrieval filter did the rest.
Every answered query appends one JSON line to the audit log — who asked, over which tenant/env, which chunk ids were retrieved, and whether we answered or abstained. It logs chunk ids and the groundedness score, never the passage text — a trail, not a second copy of sensitive data:
Teaching-grade reference implementation, not a production insurance product. It reproduces the ideas; bring your own data and keys. MIT-licensed.
Explain it back
A teammate says: “We already tell the model, in the system prompt, to never reveal anything the user isn’t cleared for. Isn’t that access control?” What’s wrong with that, in one breath?
Reveal a model answer
By the time the model reads the system prompt, the restricted passage is already in its context — it was retrieved. “Please don’t say this” is a request, not a boundary; models don’t obey it reliably, and even a perfectly obedient model has still processed data the caller was never allowed to touch. Real access control keeps restricted data out of retrieval entirely: the filter is built from the caller’s signed token (tenant, env, roles) and pushed into the query, so the restricted chunk is never returned, never seen, never leakable. Not-retrieved is the only safe state.
Now the model only receives passages the caller is allowed to read. The next job is making it answer only from them — every sentence tied to the file it came from, and an honest abstain when the passages don’t support a claim.
Every answer cites the file it came from — or it abstains →