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?”

MechanismExampleWhat the pipeline must do
Direct grantDocument shared with a user or a groupRead the principal identifiers, not display names or email addresses that can change
InheritanceA folder is shared with a group, and documents in it inherit that grantResolve the effective permission, including inheritance from every ancestor and any break in inheritance
Group membershipA grant to a group that contains users and other groupsKeep group identifiers in the index and resolve membership separately, or expand membership with a plan for updates (see below)
Link sharingAnyone in the organization with the link can readMap to an explicit principal such as “all employees”. Do not treat a missing ACL as public
Deny entriesAn exception that removes one user from an otherwise broad grantPreserve the deny, or resolve it before indexing. A plain allow-list filter silently ignores denies
Per-part permissionA page in a wiki, a sheet in a workbook, an attachment on a threadIndex 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.

ArtifactPermission ruleFailure if ignored
Chunk of one documentSame as the document at the same versionNew text paired with an old permission, or the reverse
Summary or digest of several documentsIntersection of the inputsA summary reveals a restricted input to readers of the public one
Query-result cache entryPrincipal set and permission version of the original requesterA later user receives results the first user was allowed to see
Embedding of a chunkSame as the chunkA vector copy remains searchable after the text is hidden or deleted
Evaluation set built from real questionsSame as the documents and the askerTests 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 timeMembers expanded onto chunks at index time
Index sizeSmall: a few identifiers per chunkGrows with group size times chunks
Membership changeTakes effect at the next resolution; no chunk rewriteRewrites every chunk the group can read
Query costOne membership lookup per request, cacheable with a short lifetimeOne identifier per request
What can go staleThe membership cacheThe whole permission copy
Nested groupsResolved by the directory service at query timeFlattened by the pipeline, which must recompute when any ancestor changes
Where it fitsLarge or frequently changing groups, nested directoriesSmall, 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.

PatternHow the user’s identity reaches the retrieverWhat it trustsTypical failure
Broad service identity, application-level filterThe application filters results after retrieval using its own lookupApplication code on every code pathOne path (autocomplete, export, evaluation) forgets the filter
Principal set passed as claimsThe application authenticates the user, builds the principal set server-side, and passes it to the retriever over an authenticated internal callThe application, and the integrity of the internal channelThe set is built from a client-supplied header or from the model output
Delegated or exchanged user tokenThe retriever, or the data store behind it, receives a token that names the user and the acting serviceThe token issuer and the data store’s own enforcementToken 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.

PlacementHow it worksConfidentialityRecall and cost
Pre-filter inside the vector searchThe permission predicate is part of the nearest-neighbor query, so the search only considers allowed chunksStrong if the predicate is complete and every retrieval path uses itReturns k permitted results when enough exist; a selective filter can make graph-based search slower
Post-filter after the vector searchThe engine returns the top candidates, then disallowed ones are droppedSafe only if nothing is shown or generated before the dropCan return fewer than k, or none, when the user can see few chunks
Source recheck before useThe top candidates are checked against the source system’s current permissions before their text enters the promptStrongest against stale copies; also enforces rules that cannot be precomputedAdds a call per candidate or per batch; depends on source availability
Separate index or partition per audienceEach tenant or audience has its own index, table, or partitionStrong boundary for coarse groupsMany 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 candidatesProbability that none surviveCandidates needed for an expected 10
50%5.00.1%20
10%1.034.9%100
1%0.190.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.

StageAssumed worst caseNotes
Change is noticed (event delivery, or polling as fallback)15 minPolling interval dominates if webhooks are missed
Change is processed and ACL written to the index2 minQueue lag and index update
Cached retrieval results expire30 minCache key must include principal set and permission version
Cached full answers expire24 hA cached answer contains text; the index change does not touch it
Group membership cache (user removed from a group)10 minAlternative: 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.

DimensionCases to include
PrincipalDirect member; non-member; former member; member through a nested group; guest or external user; service account; user whose identity cannot be mapped
Document permissionPrivate to one user; group; inherited from a folder; inherited with a break; link-shared; allowed with a deny exception; changed after indexing; deleted
PathVector search; keyword search; hybrid merge; reranker; snippet or neighbor expansion; citation; autocomplete; cached retrieval; cached answer; conversation memory; export or evaluation tooling
TenantSame 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

FailureSymptomControlTest
Permission not copied to chunkRestricted text answers an unrelated userBlock indexing of chunks with no determinable permissionCanary with a restricted ACL; non-member query
Service identity used for queriesFilter bug exposes the whole indexPer-request principal context; delegated identity where supportedCross-tenant and non-member query on every path
Stale permission copyRemoved user still retrieves documentsSource recheck for restricted classes; permission version in cachesRevocation timing with a canary
New content with old permissionA revoked user sees a newly added sectionVersion-matched permission fetch; chunk unsearchable until fetchedEdit-after-revoke case in the matrix
Post-filter starvationEmpty answers for users with narrow accessPre-filter, iterative scans, partitions, exact search for selective filtersRecall comparison with exact search over visible corpus
Cache crosses principalsOne user receives another user’s answerPrincipal set and permission version in cache keysSame query as two users, with revocation between
Over-broad source permissionSearch makes an old overshare discoverableFix permission at the source; flag broad shares before indexingReport 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


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.