Preserving Access Controls in Retrieval Data
Consider an illustrative scenario. A company builds an internal assistant over its shared documents. A pipeline crawls the document system every night, splits each file into passages, computes embeddings, and loads them into a search index. A contractor asks, “What is changing for the support team next quarter?” The assistant answers with a paragraph from a draft restructuring plan. In the document system, that draft is shared with three managers. The contractor could not have opened it, and the crawler could: it ran under a service account that can read everything, and the index records only text, an embedding, and a title.
Nothing in this scenario required an attack. The source system enforced its permissions correctly, the index returned the passages most similar to the question, and the model summarized what it was given. The failure sits in the copy. A retrieval index is a derived copy of the source documents, and a copy does not inherit permissions unless the pipeline carries them across and the query path enforces them. This post follows a permission from the source document to the final answer and shows where it can be lost at each step.
It is the third post in a sequence on data for AI. The previous post, Document Pipelines for Retrieval: Parsing, Versions, and Deletion, covers document provenance, parsing and chunk metadata, incremental refresh, deletion, and ingestion validation. This post assumes a pipeline of that kind exists and that every chunk already carries a source document identifier and version. It then adds what that pipeline must also carry for authorization, and it explains the access-control mechanics itself rather than leaving them to another post. The reader is assumed to know general database and API concepts. Terms specific to retrieval, such as retrieval-augmented generation (RAG), embeddings, and chunking, are used in their ordinary sense. A reminder of the vocabulary: a principal is a user, group, or service that can be granted access; a permission entry, or ACL entry, grants or denies an action on a resource to a principal.
The invariant to preserve
Access control for retrieval reduces to one statement that can be tested. For a principal u, a query q, and a time t, every chunk that influences the answer must come from a source document that the source system would let u read at time t. Written as a set relation, the retrieved set R(u, q, t) must be a subset of the visible set V(u, t), where V(u, t) is the set of chunks whose source documents the source system authorizes u to read at t. The statement has three parts that fail independently.
- Which source decides. The source system, not the index, is the authority. The index holds a copy of the permission, and a copy can be stale, incomplete, or mapped to the wrong identity.
- Who u is. The principal must be the end user asking, as established by the application, and not the service account that built the index or the model that writes the answer.
- What counts as influencing the answer. Retrieved chunks are the obvious path. Cached retrievals, cached answers, conversation memory, citations, snippets, and “related document” suggestions are others, and each can carry text across a principal boundary.
A second requirement is easy to forget because it is not about secrecy. R(u, q, t) should also contain the permitted chunks that the query would have found if the user had a private copy of the corpus. A filter that removes everything the user cannot see but also starves the result list is a different failure: the user gets a worse answer, and the pressure to “fix” it usually leads someone to loosen the filter. The sections below keep both properties in view: no extra chunks (confidentiality) and no missing permitted chunks (recall).
Retrieval does not create new permissions, and it is not a security boundary by itself. It does change who can find a document. A file shared too broadly years ago was protected mostly by being hard to discover. A semantic search over the whole corpus removes that obscurity, so faithfully copying an over-broad permission can still produce a visible incident. The limits section returns to this point.
Document and chunk permissions
What a source permission actually is
A source permission is more than a list of names. Most document systems combine several mechanisms, and the pipeline has to turn them into one effective answer to the question “who can read this document now?”
| Mechanism | Example | What the pipeline must do |
|---|---|---|
| Direct grant | Document shared with a user or a group | Read the principal identifiers, not display names or email addresses that can change |
| Inheritance | A folder is shared with a group, and documents in it inherit that grant | Resolve the effective permission, including inheritance from every ancestor and any break in inheritance |
| Group membership | A grant to a group that contains users and other groups | Keep group identifiers in the index and resolve membership separately, or expand membership with a plan for updates (see below) |
| Link sharing | Anyone in the organization with the link can read | Map to an explicit principal such as “all employees”. Do not treat a missing ACL as public |
| Deny entries | An exception that removes one user from an otherwise broad grant | Preserve the deny, or resolve it before indexing. A plain allow-list filter silently ignores denies |
| Per-part permission | A page in a wiki, a sheet in a workbook, an attachment on a thread | Index each part as its own access unit, not as part of the parent file |
Source systems differ in how many of these they have, and their documentation, not the pipeline author, defines the semantics. The first engineering task is therefore to write down, for each source, how effective permissions are computed and which API returns them. The second is to normalize them into one representation the index can enforce. Normalizing means converting source-specific identifiers into stable principal identifiers, converting link-sharing and public-style grants into explicit principals, and recording what the pipeline could not determine. A chunk whose permission could not be determined is not “unrestricted”. It is not retrievable.
From documents to chunks
Chunking changes the unit of retrieval but not the unit of permission. A chunk is a slice of a document, and the source system granted access to the document. The rule is that a chunk’s permission is exactly the permission of the document or document part it came from, evaluated at the version the chunk was cut from. The pipeline therefore copies the permission onto every chunk, along with the source identifier and version that already travel as chunk metadata.
The record below is an illustrative chunk schema with the fields that authorization needs. It is a design sketch, not the format of any particular engine, and it was not executed.
{
"chunk_id": "doc-4821#v7#c12",
"doc_id": "doc-4821",
"doc_version": 7,
"tenant": "tenant-a",
"allow_principals": ["group:support-managers", "user:u-1093"],
"deny_principals": [],
"acl_version": 311,
"acl_fetched_at": "2024-11-12T02:14:09Z",
"classification": "restricted",
"text": "...",
"embedding": [ ... ]
}
Three fields deserve explanation. acl_version and acl_fetched_at make the age of the copied permission measurable, which the revocation section depends on. tenant is a separate, mandatory predicate for multi-tenant systems, in the sense described by tenant isolation: a tenant boundary should be enforced as its own condition and not inferred from the group list. classification lets the query path apply stricter handling, such as a source recheck, to restricted material without paying for it everywhere.
Some chunks have more than one source. A summary of several documents, a table assembled from two files, or a generated “document digest” each depends on all of its inputs. The only safe rule is that a derived artifact is readable by the intersection of the readers of its inputs, and that it is rebuilt or removed when any input’s permission narrows. If that is too costly to maintain, do not store derived artifacts across permission boundaries at all. Summaries generated per request, from chunks the requester can already read, avoid the problem.
| Artifact | Permission rule | Failure if ignored |
|---|---|---|
| Chunk of one document | Same as the document at the same version | New text paired with an old permission, or the reverse |
| Summary or digest of several documents | Intersection of the inputs | A summary reveals a restricted input to readers of the public one |
| Query-result cache entry | Principal set and permission version of the original requester | A later user receives results the first user was allowed to see |
| Embedding of a chunk | Same as the chunk | A vector copy remains searchable after the text is hidden or deleted |
| Evaluation set built from real questions | Same as the documents and the asker | Tests become a path that copies restricted text to engineers |
Groups: expand at index time or resolve at query time
The largest design choice in the schema is how to represent group grants. There are two approaches, and their costs differ by orders of magnitude.
In the first approach, the index stores group identifiers on each chunk, and at query time the application resolves the requesting user into a set of identifiers: the user identifier and every group the user belongs to, including nested groups. The filter asks whether any identifier in that set appears in the chunk’s allow list. A membership change takes effect when the application next resolves membership, and no chunk is rewritten.
In the second approach, the index stores user identifiers, and the pipeline expands every group grant into its members when it writes the chunk. Query-time filtering is then a single-identifier lookup. The cost is in storage and update traffic. As a calculation example with assumed numbers, suppose a group of 5,000 users has access to 200,000 chunks. Expanding the group onto each chunk writes 5,000 × 200,000 = 1,000,000,000 principal entries. At an assumed 16 bytes per entry, that is about 16 GB of permission data for one group before replication. Adding one person to the group rewrites 200,000 chunks. Removing one person does the same, and removal is the change that must not lag.
| Group identifiers on chunks, resolved at query time | Members expanded onto chunks at index time | |
|---|---|---|
| Index size | Small: a few identifiers per chunk | Grows with group size times chunks |
| Membership change | Takes effect at the next resolution; no chunk rewrite | Rewrites every chunk the group can read |
| Query cost | One membership lookup per request, cacheable with a short lifetime | One identifier per request |
| What can go stale | The membership cache | The whole permission copy |
| Nested groups | Resolved by the directory service at query time | Flattened by the pipeline, which must recompute when any ancestor changes |
| Where it fits | Large or frequently changing groups, nested directories | Small, stable audiences where query latency dominates |
The first approach is the usual default for enterprise directories because the quantity that changes fastest, membership, is kept out of the large data set. The cost is a runtime dependency: the query path needs a membership resolver that is available, fast, and itself correct. If it fails, the safe behavior is to retrieve nothing, or only content with no group restriction, and to say so.
Identity propagation
A filter needs principals, and principals have to come from somewhere trustworthy. The question is whose authority the retrieval runs with, and the answer decides whether the filter protects anything.
Service identity versus end-user identity
A service account used by the pipeline to read source documents is an ordinary and necessary part of ingestion. The mistake is to let the same kind of broad identity serve user queries unchecked. A retrieval service that can read the entire index on behalf of everyone is a confused deputy: it holds more authority than any caller, and the only thing between a caller and that authority is application logic that someone has to get right on every request. The idea in least privilege applies here directly. A broadly privileged service should not lend all its privileges to every user request.
There are three common ways to give the retrieval path the end user’s identity.
| Pattern | How the user’s identity reaches the retriever | What it trusts | Typical failure |
|---|---|---|---|
| Broad service identity, application-level filter | The application filters results after retrieval using its own lookup | Application code on every code path | One path (autocomplete, export, evaluation) forgets the filter |
| Principal set passed as claims | The application authenticates the user, builds the principal set server-side, and passes it to the retriever over an authenticated internal call | The application, and the integrity of the internal channel | The set is built from a client-supplied header or from the model output |
| Delegated or exchanged user token | The retriever, or the data store behind it, receives a token that names the user and the acting service | The token issuer and the data store’s own enforcement | Token lifetime and group claims lag behind revocation |
The standards vocabulary for the third pattern is useful even if no token is exchanged. RFC 8693, the OAuth 2.0 token exchange specification, distinguishes two semantics. In impersonation, one principal is given the rights of another and is indistinguishable from it in the granted context. In delegation, the acting service keeps its own identity and acts on behalf of the user, and a composite token can record both the subject and the actor, with an act claim that can express a chain of delegation. For retrieval, delegation is usually the better fit: the data store can enforce the user’s permissions while the audit trail still shows that the retrieval service acted. The RFC leaves the representation of composite tokens that are not JWTs outside its scope and makes issuance a decision of the authorization server, so the choice depends on what your identity provider and data store support.
Rules for the principal context
- Derive it on the server from a verified credential. A supplied tenant identifier, group name, or email address is a claim to be checked, not a fact. A request must not choose its own security boundary. The same holds for the model: an instruction in the prompt that says “only reveal what this user may see” does not change what the retriever returns, and a language model is not a place to enforce authorization.
- Fail closed on unmapped identities. The identity provider’s subject, the source system’s user identifier, and the identifier stored on chunks are often three different strings. If the mapping for a user is missing, the safe result is no private content, not “all content” and not “match by email prefix”.
- Know how old each claim is. If group membership is carried in an access token issued at login, it is as old as the token. A self-contained token validated locally does not learn about revocation before it expires unless the verifier also checks live state. If the application instead resolves membership from the directory with a cache, the cache lifetime is the bound. Write the bound down; it feeds the revocation window below.
- Pass identity through every hop that reads data. Retrieval, reranking that fetches additional text, snippet expansion, and any “show me the surrounding section” call must all use the same principal context. A hop that re-queries the source with a service identity reopens the gap.
- Record decisions without recording content. An audit log entry should hold the principal set (or its hash), the filter version, the permission version, and the returned chunk identifiers. It should not hold the text of restricted chunks, which would create yet another unprotected copy.
The decision and the enforcement are also separate roles. A policy decision point evaluates rules, and a policy enforcement point carries out the result at the place the request cannot bypass. In retrieval, the filter inside the search engine, or the recheck against the source before text enters the prompt, is the enforcement point. A table of rules in a configuration file is evidence of intent, not of enforcement, until a test shows that a request by another route is blocked.
Finally, the model of access control matters less than its inputs. Whether the source uses roles or attributes (role-based and attribute-based access control compares them), the index copy holds the result of an evaluation at one moment. Attribute-based rules that depend on request context, such as device state or time of day, cannot be fully precomputed onto chunks. Treat such rules as a reason to use a source recheck, discussed next.
Filtering at retrieval time
With principals and per-chunk permissions in hand, the retrieval query has to apply them. Four placements are common. They differ in whether the filter changes which candidates the vector search considers, how many results survive, and whether the source system is consulted.
| Placement | How it works | Confidentiality | Recall and cost |
|---|---|---|---|
| Pre-filter inside the vector search | The permission predicate is part of the nearest-neighbor query, so the search only considers allowed chunks | Strong if the predicate is complete and every retrieval path uses it | Returns k permitted results when enough exist; a selective filter can make graph-based search slower |
| Post-filter after the vector search | The engine returns the top candidates, then disallowed ones are dropped | Safe only if nothing is shown or generated before the drop | Can return fewer than k, or none, when the user can see few chunks |
| Source recheck before use | The top candidates are checked against the source system’s current permissions before their text enters the prompt | Strongest against stale copies; also enforces rules that cannot be precomputed | Adds a call per candidate or per batch; depends on source availability |
| Separate index or partition per audience | Each tenant or audience has its own index, table, or partition | Strong boundary for coarse groups | Many small indexes cost operations effort; per-user indexes are impractical |
What documented engines do
Two widely used engines document this behavior, and both were read for this post on 3 October 2026. The behavior can change between versions, so check the version you run.
The pgvector extension for PostgreSQL documents that with approximate indexes, filtering is applied after the index is scanned. Its example is concrete: if a condition matches 10% of rows and the HNSW index uses the default hnsw.ef_search of 40, only about four rows match on average, and queries with filtering can return fewer results. Starting with version 0.8.0, iterative index scans keep scanning until enough results are found or a configured limit is reached. The same documentation warns that when tenants share an approximate index, vectors from one tenant can affect recall and speed for others, and it recommends list partitioning or separate tables for tenant isolation.
Elasticsearch documents the same distinction by placement. A filter inside the knn clause is applied during the approximate search so that k matching documents are returned. A filter placed outside it, for example as a sibling filter in a bool query, runs after the vector search. Then the response can contain far fewer than k hits, or none, even when the index holds many similar permitted documents. The same page notes that a filter can make an HNSW search slower rather than faster, because the search must explore more of the graph to collect enough matching candidates, and that Lucene falls back to scoring the filtered documents directly in some cases.
Both documents describe recall and speed, not security. A post-filter that runs before anything is displayed does not leak. What it does is starve the result list, and that turns into a security problem indirectly: engineers see empty answers for users with narrow access and respond by increasing the candidate count, relaxing the filter, or moving it later in the pipeline. The confidentiality failures come from a different set of mistakes, listed after the numbers.
A calculation: how selectivity starves a post-filter
The table below is a calculation example, not a measurement. Assume a post-filter keeps a candidate with probability p, which is the share of chunks the user may read, and that relevance is independent of permission. The expected number of permitted results among k = 10 candidates is 10p. The probability that none of the 10 is permitted is (1 − p)10. The number of candidates needed for an expected 10 permitted results is 10 / p.
| Share of corpus the user may read (p) | Expected permitted results among 10 candidates | Probability that none survive | Candidates needed for an expected 10 |
|---|---|---|---|
| 50% | 5.0 | 0.1% | 20 |
| 10% | 1.0 | 34.9% | 100 |
| 1% | 0.1 | 90.4% | 1,000 |
The independence assumption is optimistic for the user with little access. Queries tend to retrieve documents about the topic the user is asking about, and restricted topics are over-represented among top candidates when the user is outside the group that owns them. In practice a post-filter loses more than the table suggests for exactly the users with the least access. A pre-filter does not have this problem, but it moves the cost into the search. When the filter is very selective, exact search over the filtered subset can be both correct and cheap, which is why both engines include some form of fallback to scoring the matching documents directly.
A toy simulation of starvation and stale permissions
The independence assumption can be replaced by a small model in which permissions correlate with topic. The script below is an explanatory simulation written for this post; it uses only the Python standard library (Python 3.12 was used to run it), a seeded random generator, and made-up data. It builds 2,000 chunks as eight-dimensional unit vectors in ten groups of 200, so each user may read 10% of the chunks, and each group has its own topic direction. It then compares three retrieval strategies for one user over 500 random queries, and in part B it changes permissions in the “source” without updating the “index” copy and runs an oracle-based leakage check.
import random, math
random.seed(7)
DIM, N, G = 8, 2000, 10
def unit(v):
n = math.sqrt(sum(x * x for x in v))
return [x / n for x in v]
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
# Illustrative corpus: each chunk has a vector and a source group. Group centers make
# topics correlate with permissions, as they do in real document systems.
centers = [unit([random.gauss(0, 1) for _ in range(DIM)]) for _ in range(G)]
chunks = []
for i in range(N):
g = i % G
v = unit(])
chunks.append({"id": i, "vec": v, "src_group": g, "idx_group": g})
def top_k(q, pool, k):
return sorted(pool, key=lambda c: -dot(q, c["vec"]))[:k]
def post_filter(q, user_group, k, depth):
cands = top_k(q, chunks, depth)
return == user_group][:k]
def pre_filter(q, user_group, k):
return top_k(q, == user_group], k)
queries = [unit([random.gauss(0, 1) for _ in range(DIM)]) for _ in range(500)]
user_group = 3
print("A. results returned per query (k=10), user may read 10% of chunks")
for name, fn in [("post-filter, depth 10", lambda q: post_filter(q, user_group, 10, 10)),
("post-filter, depth 100", lambda q: post_filter(q, user_group, 10, 100)),
("pre-filter", lambda q: pre_filter(q, user_group, 10))]:
sizes = [len(fn(q)) for q in queries]
print(f" {name:24s} mean={sum(sizes)/len(sizes):4.1f} empty={sum(s == 0 for s in sizes):3d} of {len(sizes)}")
# Revocation: in the source, 60 chunks of group 3 move to group 4. The index copy is not updated.
moved = == 3][:60]
for c in moved:
c["src_group"] = 4
def oracle_can_read(group, chunk): # independent source of truth
return chunk["src_group"] == group
print("B. leakage test against the source of truth after a source-side permission change")
viol_index, viol_recheck, checked = 0, 0, 0
for q in queries:
res = pre_filter(q, user_group, 10) # filter uses the (stale) index copy
viol_index += sum(not oracle_can_read(user_group, c) for c in res)
rechecked =
viol_recheck += sum(not oracle_can_read(user_group, c) for c in rechecked)
checked += len(res)
print(f" chunks returned={checked}, violations with index-only filter={viol_index}, with source recheck={viol_recheck}")
qs_hit = sum(any(not oracle_can_read(user_group, c) for c in pre_filter(q, user_group, 10)) for q in queries)
print(f" queries with at least one violation (index-only filter): {qs_hit} of {len(queries)}")
The output from running the script was:
A. results returned per query (k=10), user may read 10% of chunks
post-filter, depth 10 mean= 1.3 empty=275 of 500
post-filter, depth 100 mean= 5.7 empty= 78 of 500
pre-filter mean=10.0 empty= 0 of 500
B. leakage test against the source of truth after a source-side permission change
chunks returned=5000, violations with index-only filter=1371, with source recheck=0
queries with at least one violation (index-only filter): 480 of 500
Part A shows the starvation effect on this synthetic data. The post-filter over 10 candidates returned 1.3 permitted chunks per query on average and returned nothing for 275 of 500 queries. Raising the candidate depth to 100 raised the average to 5.7 and left 78 empty queries, still short of ten. The pre-filter returned ten results for every query, because it searched only the permitted subset. The numbers are properties of this toy corpus and of random queries, not predictions for a real index, but they have the shape the earlier calculation predicts: the cost of a post-filter falls on users who can see little, and a deeper candidate list helps without removing the problem.
Part B is explained in the leakage-test section below. It shows why a filter that reads the index copy of a permission cannot detect that the copy is stale, and why an independent recheck can.
Where confidentiality is actually lost
A complete filter on the vector query is necessary and not sufficient. These are the places where restricted text or evidence of it still crosses the boundary.
- A second retriever. Hybrid search that combines vector results with keyword results needs the same filter on both. A lexical index built separately, such as a search index maintained by another team, is a different copy with its own permission data.
- Query expansion and reranking over unfiltered text. If a first pass retrieves from the whole corpus to rewrite the query or to pick a reranking context, restricted text has influenced the answer even if it is later dropped from the list.
- Caches. A cache keyed by query text returns a result computed for someone else. Keys must include the principal set and a permission version, or caching must be per user. The authorization check must also apply to cached and derived results, not only to fresh source reads.
- Presentation fields. Titles, snippets, citation labels, autocomplete suggestions, “related documents”, and result counts are derived from restricted documents. A message that says “3 restricted documents matched” tells the user something.
- Deleted or hidden chunks that are still searchable. Whether a tombstoned or soft-deleted vector is excluded until the index is compacted depends on the engine. Verify that retrieval excludes such records, and see delete tombstone for why deletion markers need to outlive replays.
- Evaluation and debugging tools. A notebook that samples real questions with their retrieved chunks copies restricted text into an environment with different access rules.
Deny by default, and recheck what matters
The filter expression should be generated from the principal context on every request and should fail closed. The sketch below is illustrative and not tied to a specific engine. An empty principal set matches nothing, a chunk with no permission field is excluded, and denies are checked in addition to allows.
filter = AND(
tenant == ctx.tenant,
acl_allow INTERSECTS ctx.principals, # empty set -> no match
NOT ( acl_deny INTERSECTS ctx.principals ),
acl_version IS PRESENT # unknown permission -> not retrievable
)
For material classified as restricted, or for sources whose rules cannot be precomputed, add an authorization recheck before text enters the prompt. The sketch below is pseudocode, not a tested implementation, and the source call is a placeholder.
candidates = vector_search(query, filter=filter, k=40)
allowed = source_check_batch(principal=ctx.user, docs=)
context = [:10]
if source_check_unavailable:
context = # fail closed
The recheck costs one batched call per request, and it makes revocation latency for restricted material equal to the source system’s own, because the stale index copy no longer decides. It also has a limit. It protects the context passed to the model, not the cache, the logs, or text the user has already seen. For authorization systems that must give consistent answers across replicated stores, Google’s Zanzibar paper describes the extra machinery needed, discussed in the next section.
Revocation: narrowing is the dangerous direction
Permission changes come in two directions. When access is granted, the failure of a stale copy is a missing result: the user cannot find a document they should see yet. When access is narrowed, the failure is exposure: the user can still find a document they should no longer see. The pipeline should treat these asymmetrically, with narrowing changes processed first and tracked against a stated deadline. The matching glossary term is access revocation, which stresses that a removal must reach every path that actually authorizes requests, and that it cannot make a recipient forget what was already seen.
The exposure window
Between the moment a source permission narrows and the moment the last copy stops serving the content, there is an exposure window. It is the sum of delays along a sequential path plus the lifetimes of caches the path feeds. The table uses assumed values to show where the time goes. Every value is an illustrative assumption, not a measurement of any product.
| Stage | Assumed worst case | Notes |
|---|---|---|
| Change is noticed (event delivery, or polling as fallback) | 15 min | Polling interval dominates if webhooks are missed |
| Change is processed and ACL written to the index | 2 min | Queue lag and index update |
| Cached retrieval results expire | 30 min | Cache key must include principal set and permission version |
| Cached full answers expire | 24 h | A cached answer contains text; the index change does not touch it |
| Group membership cache (user removed from a group) | 10 min | Alternative: group claims in a token last until the token expires, for example 60 min |
For a document that is unshared from a group, the worst-case exposure is about 15 + 2 = 17 minutes until the index is updated, then up to 30 more minutes for retrieval caches, so roughly 47 minutes. If full answers are cached for 24 hours, the same change leaves text available for about 24 hours and 47 minutes through the answer cache. The largest term is the one nobody thought of as part of the permission system. For a user removed from a group, the window is set by whichever membership source the application trusts: the directory cache lifetime (10 minutes in this example) if it resolves membership per request, or the token lifetime if group claims live in the token.
Reducing the window means attacking the largest terms. Key every cache by principal set and permission version so that a permission change makes entries unreachable instead of waiting for expiry. Do not cache full answers across principals, and prefer to cache retrieval results per user or per principal set. Apply a source recheck to restricted classes so that the index copy stops being the authority. Process narrowing events ahead of grants. State the intended window per sensitivity class, and test it, as the next section describes.
Ordering: the “new enemy” problem
Two updates can interact so that a revoked user briefly regains access, even when each update is correct on its own. The Zanzibar paper, which describes Google’s global authorization system, names this the “new enemy” problem and gives two examples that map directly to retrieval pipelines.
- Neglecting ACL update order. A user is removed from a folder’s ACL; then new documents are moved into the folder and inherit the folder ACL. If a check evaluates against a snapshot that has the second change but not the first, the removed user sees the new documents.
- Applying an old ACL to new content. A user is removed from a document’s ACL; then new content is added to that document. If the content is evaluated with an ACL from before the removal, the user sees the new content.
In a pipeline, the second case appears when chunking and permission lookup are separate steps with separate clocks. A chunk is cut from version 8 of a document, but its permission was fetched at the time version 7 was indexed. The first prevents itself only if the permission snapshot used for a document is at least as new as the change that put the document there. Zanzibar handles this with an opaque consistency token stored with each content version and presented on checks, so the check snapshot is at least as fresh as the content. Without that machinery, a pipeline can approximate the property with a simpler rule: a chunk becomes searchable only after its permission has been fetched at or after the time its content version was created, and the permission and the content are written with the same version identifier. If the permission fetch fails, the new chunk stays unsearchable until it succeeds. This is a design suggestion drawn from the paper’s two examples, not a claim about how any particular product behaves.
Deletion, identity changes, and drift
Deleting a document is the strongest form of narrowing, and the retrieval index has to remove the text and the vector. A deletion that only hides the chunk from one query path leaves a searchable copy. The earlier post on document pipelines covers deletion in the ingestion flow, and the check here is the retrieval-side one: after deletion, a query that matches the text exactly returns nothing for any principal.
Identity changes follow the same logic. When a user leaves, changes teams, or a group is restructured, the directory changes first and every copy of the membership follows. The identity lifecycle defines the joiner, mover, and leaver events that should trigger them. Sessions already open, cached membership, and conversation memory may outlive the directory change, so the application needs a way to invalidate them.
Finally, an event-driven design drifts. Webhooks are lost, ACL endpoints return partial data, a group is renamed, a rule changes in a source system without an event. The defense is a periodic reconciliation that compares a sample of chunk permissions with the source and records the mismatch rate and the age of the oldest acl_fetched_at. A full resynchronization of permissions, independent of content refresh, is the repair after a mapping bug is found.
Leakage tests
An access-control mechanism has not been demonstrated until a test tries to cross the boundary and fails. The tests below are described as designs and need no particular tooling. Each answers a different question, and none of them substitutes for the others.
An oracle independent of the index
The central test compares retrieval results with an independent statement of who may read what. For each sampled principal and query, run the real retrieval path, then for each returned chunk ask a separate oracle whether the principal may read the source document. The oracle should be the source system, or a snapshot of its permissions taken by a different code path than the pipeline uses. If the oracle reads the same permission field that the filter uses, the test compares the index with itself and passes whenever the field is wrong in the same way.
for principal in sampled_principals:
for query in sampled_queries:
returned = retrieve(query, as_user=principal) # real path, real filter
for chunk in returned:
assert oracle_can_read(principal, chunk.doc_id) # source of truth, not index field
exact = brute_force_top_k(query, corpus=visible_to(principal)) # same query, private corpus
missing = exact.top_k_ids - returned_ids # recall loss, tracked separately
The snippet is a sketch of the test logic and was not run. It makes the two properties from the beginning explicit. Any returned chunk the oracle rejects is a confidentiality violation. The second check compares the filtered approximate search with an exact search over the principal’s private corpus. That is the “private copy” idea: the system should behave as if each user searched only their own visible documents, up to the approximation error of the index.
Part B of the simulation above applies this logic. Sixty chunks of group 3 are moved to group 4 in the source, and the index copy is left unchanged. The leakage check, which asks the source of truth about every returned chunk, found 1,371 violations among 5,000 returned chunks, and 480 of the 500 queries had at least one. Dropping the chunks that fail a source recheck before use brought the violations to zero. The index-only filter could not have found these errors, because it agrees with its own copy; the independent oracle did. The result is a property of this toy setup, in which 60 of 200 chunks changed groups and the moved chunks make up 30% of what the stale index lets this user see. It illustrates the mechanism, not an expected rate.
Canary documents
Canaries are planted documents with known permissions and unmistakable content, such as a unique nonsense phrase. Create a canary readable only by one test group, run the pipeline normally, then query the exact phrase as a member and as several non-members. A member must find it, which confirms the canary was indexed and the test is capable of failing. Non-members must not. The same canary measures revocation. Remove access from the test group, record the time, and poll as the former member until the canary disappears from retrieval, from cached results, and from generated answers. The measured delay is the exposure window the system actually has, compared with the window you intended.
A coverage matrix
Sampling random queries finds common problems and misses rare permission shapes, so the cases themselves should be enumerated. Cross the principal types with the permission shapes and the retrieval paths.
| Dimension | Cases to include |
|---|---|
| Principal | Direct member; non-member; former member; member through a nested group; guest or external user; service account; user whose identity cannot be mapped |
| Document permission | Private to one user; group; inherited from a folder; inherited with a break; link-shared; allowed with a deny exception; changed after indexing; deleted |
| Path | Vector search; keyword search; hybrid merge; reranker; snippet or neighbor expansion; citation; autocomplete; cached retrieval; cached answer; conversation memory; export or evaluation tooling |
| Tenant | Same tenant, different group; different tenant; request that supplies another tenant’s identifier |
Seven principal types crossed with eight permission shapes give 56 combinations before paths are considered. That is small enough to run on every change to the pipeline or the filter.
What the tests can and cannot show
A passing suite shows that the tested cases did not leak. It does not show that no case leaks. Random sampling has a calculable limit. If a defect affects a fraction r of random queries, then n independent random queries find at least one affected query with probability 1 − (1 − r)n. For a 95% chance of seeing it, n must be at least ln(0.05) / ln(1 − r). At r = 1% that is 299 queries, at 0.1% it is 2,995, and at 0.01% it is 29,956. The calculation assumes independent queries and a fixed defect rate. A leak tied to one rare permission shape, such as a deny exception on an inherited folder, may affect almost no random queries, which is why those shapes need directed tests.
End-to-end tests add a layer because a generated answer can reveal text without citing it. Ask questions whose answers appear only in canaries. Test across users in the same conversation store and cache. Include inputs that try to change the principal, such as a prompt telling the system to act for an administrator; the correct result is that authorization does not change. The correctness of the oracle matters too: if the source system’s own permissions are wrong, a test that faithfully matches them passes.
In production, the same checks run continuously on a sampled fraction of real requests. Useful signals are the number of returned chunks the oracle rejects (which should be zero), the age of the oldest permission copy, the rate of unmapped identities, the rate at which the empty-filter fail-closed path triggers, and the reconciliation mismatch rate. A spike in empty results is a security-relevant signal because it can indicate either a mapping outage or a filter that is working.
Limits and failure modes
| Failure | Symptom | Control | Test |
|---|---|---|---|
| Permission not copied to chunk | Restricted text answers an unrelated user | Block indexing of chunks with no determinable permission | Canary with a restricted ACL; non-member query |
| Service identity used for queries | Filter bug exposes the whole index | Per-request principal context; delegated identity where supported | Cross-tenant and non-member query on every path |
| Stale permission copy | Removed user still retrieves documents | Source recheck for restricted classes; permission version in caches | Revocation timing with a canary |
| New content with old permission | A revoked user sees a newly added section | Version-matched permission fetch; chunk unsearchable until fetched | Edit-after-revoke case in the matrix |
| Post-filter starvation | Empty answers for users with narrow access | Pre-filter, iterative scans, partitions, exact search for selective filters | Recall comparison with exact search over visible corpus |
| Cache crosses principals | One user receives another user’s answer | Principal set and permission version in cache keys | Same query as two users, with revocation between |
| Over-broad source permission | Search makes an old overshare discoverable | Fix permission at the source; flag broad shares before indexing | Report of broad-audience restricted documents (an audit, not a retrieval test) |
Several limits remain whatever the controls. Revocation cannot recall what a user already read, copied, or received in an exported answer. An embedding can carry information about its source text, so a vector that is stored must carry the permission of its chunk, and the OWASP guidance on vector and embedding weaknesses recommends permission-aware vector stores with logical and access partitioning between groups. Combining several permitted passages can reveal something no single passage states; that inference problem is not solved by per-document permissions. And all of this assumes the source system’s permissions are the intended ones. Retrieval faithfully reproduces whatever the source grants.
The same discipline applies to the next stage of data for AI. Training and evaluation sets built from these documents or from the questions asked of them are further copies, with permissions, provenance, and deletion obligations of their own. The next post, Curating Training Data and Managing Feedback, takes that up for training data.
References
- IETF RFC 8693, OAuth 2.0 Token Exchange
- Pang et al., Zanzibar: Google’s Consistent, Global Authorization System (USENIX ATC 2019)
- pgvector README: Filtering, Iterative Index Scans
- Elastic documentation: Filter approximate kNN results
- OWASP Gen AI Security Project: LLM08:2025 Vector and Embedding Weaknesses
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
