xavier-ramirez.com
← Retrieve & rerank

Your own AI Overview · 04

Access control at retrieval

Filter at retrieval, not after.

An AI Overview over your own data is only as safe as the retrieval step. Ask it “why did POL-55012’s premium go up?” and it will happily pull every passage that looks relevant — including an underwriting risk memo a broker was never allowed to see, or a near-identical claim from a different insurer on the same system. The tempting fix is to retrieve everything and then tell the model to hide the restricted parts. That leaks. This post shows the version that doesn’t: the permission check is compiled into the query, built from the caller’s signed token, and applied before a single passage reaches the model.

01 · Spine02 · Chunking03 · Retrieve & rerank04 · Access control05 · Grounding & citations06 · Evals & guardrails

↳ This post deepens one stage of the spine: the access check that runs inside retrieval.

Worked exampleThis series uses property & casualty (P&C) insurance as its running example — policies, claims and underwriting notes. The pipeline itself is domain-agnostic; the data just happens to be an insurer’s.

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.

the leak demo
Caller role (tenant: acme)
Filter at retrieval

↳ You’re the Acme broker. Leave the filter off and read what reaches the model — then switch it on.

allowed — reaches modelblocked at the gateleaked — should not be here

Brokers see policies only — no claims, no memos.

Filter off — all four documents reach the model, including 2 restricted by role and 1 from another insurer. “Please hide it” is a request, not a boundary: the model already read them.

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.

Why not read roles from the request?

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:

own_overview/security/access.py
def build_filter(identity: Identity, *, doc_types: set[str] | None = None) -> RetrievalFilter:
    if not identity.roles:
        # No roles => can retrieve nothing. Fail closed, never open.
        return RetrievalFilter(scope=identity.scope, roles=frozenset(), doc_types=frozenset())
    return RetrievalFilter(
        scope=identity.scope,
        roles=frozenset(identity.roles),
        doc_types=frozenset(doc_types) if doc_types else None,
    )


def is_visible(chunk_scope, chunk_roles: frozenset[str], flt: RetrievalFilter) -> bool:
    if chunk_scope != flt.scope:       # 1. tenant + env must match
        return False
    if not (chunk_roles & flt.roles):  # 2. at least one role must overlap
        return False
    return True

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:

own_overview/pipeline/nodes/retrieve.py
def run(state: QueryState, settings, *, embedder: Embedder, store: VectorStore) -> dict:
    identity = state["identity"]
    # The filter is derived only from the signed identity. Fail-closed logic
    # (no roles => no results) lives in build_filter / the store.
    flt = build_filter(identity)

    queries = state.get("sub_queries") or [state.get("question", "")]
    best: dict[str, Retrieved] = {}
    for q in (q for q in queries if q and q.strip()):
        vec = embedder.embed_query(q)
        for hit in store.search(vec, flt, k=settings.retrieve_k):  # filter pushed into the query
            best[hit.chunk.chunk_id] = hit
    return {"retrieved": list(best.values())}

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.

own_overview/contracts.py
@dataclass(frozen=True, slots=True)
class TenantScope:
    tenant_id: str
    env: str  # e.g. "dev", "qa", "prod"

    def namespace(self) -> str:
        """Vector-store namespace / index suffix for this scope."""
        return f"{self.tenant_id}__{self.env}"
Why this is the post that matters in a regulated insurer
  • 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__env the index partition key — so a query can’t cross it — is the honest guarantee, not a WHERE clause 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.
Run it yourself · own-overview @ v4-access-control

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.

terminal
git clone https://github.com/xaviramirezcom/open-ai-overview
cd open-ai-overview
git checkout v4-access-control
pip install -e ".[local]"

# Broker: policy only — the underwriting risk memo is never retrieved
own-overview query "Why did POL-55012's premium rise?" \
  --tenant acme --env prod --role broker

# Underwriter: same question, now the risk memo is in scope
own-overview query "Why did POL-55012's premium rise?" \
  --tenant acme --env prod --role underwriter

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:

audit.log
{"timestamp":"2026-08-13T18:04:11Z","user_id":"u_88","tenant":"acme","env":"prod",
 "question":"Why did POL-55012's premium rise?","retrieved_chunk_ids":["POL-55012#0"],
 "groundedness":0.94,"abstained":false}

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.

Next in the series · 05
Grounding & citations: answer only from what you retrieved

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 →