Skip to content
Preptima
hardConceptDesignMidSeniorStaff

How do you enforce per-user permissions in a retrieval system?

Carry the authorised principal set on every chunk and pass the user's principals into the search as a filter, so the candidate set is legal before ranking. Filtering after retrieval leaks through counts and gaps, and destroys recall for the users with the narrowest access.

6 min readUpdated 2026-07-28
Practice answering out loud

What the interviewer is scoring

  • Does the candidate put the permission predicate inside the search rather than around it
  • Whether the recall consequence of post-filtering is understood, not just the leak
  • That group expansion, inheritance and deny rules are treated as a modelling problem with real fan-out
  • Can the candidate say what still leaks when filtering is correct
  • Whether prompt instructions are explicitly rejected as an enforcement mechanism

Answer

The retrieval call has to take the user's identity as an argument, not just their question. Every chunk carries the set of principals permitted to see it, the query carries the set of principals this user holds, and the store is asked for the nearest neighbours among rows where those sets intersect. The candidate set is then legal by construction, ranking happens within it, and top-k means the best k documents this user is allowed to have.

This is the whole answer and it sounds obvious, which is why the common implementation is the opposite. Retrieve the global top fifty, ask the authorisation service which of them the caller may see, drop the rest, and pass what survives to the model. Nothing is disclosed, the code is simple, and it is wrong in two independent ways.

Post-filtering leaks, and then it fails

The leak is not the document text; it is everything around it. A result count, a pagination total, a "showing 8 of 50" label, a facet histogram, a latency profile that differs when the reranker did more work, a gap in a numbered list — each of these is a channel that tells the caller something exists. The distinction between "there is no document about the Helsinki acquisition" and "there is one and you may not read it" is exactly the fact you were trying to protect, and it survives the removal of the text. If your interface surfaces any aggregate over the pre-filter set, you have disclosed membership without disclosing content, and in most regulated settings that is the disclosure that matters.

The failure mode is worse than the leak, and it is the part that separates a strong answer. Approximate nearest-neighbour search returns a fixed budget of candidates. If the person asking has access to two per cent of the corpus, then filtering fifty global results leaves them roughly one, and it will not be the best document they were entitled to — it will be whichever of their documents happened to place in a global ranking dominated by material they cannot see. Recall does not degrade gracefully here; it collapses, and it collapses most for new joiners, contractors, external guests and the smallest tenants, who are precisely the users least able to tell that the system is failing rather than that the answer does not exist. Over-fetching more candidates is the reflex patch and it has no correct multiplier: the factor you need is the reciprocal of the user's access fraction, which varies per user and per query and is unbounded.

Filtering inside the search fixes both at once, and it has its own cost you should name. A highly selective filter makes graph traversal harder, because the reachable neighbourhood is sparse and the walk can strand in a region with no permitted rows, so effective recall drops unless you widen the search effort or the engine falls back to an exact scan over the permitted subset. Knowing that filtered search is not free, and that the tuning knob is search effort rather than candidate count, is the sign of someone who has run it.

Modelling the ACL you can afford to index

The naive representation stores a list of user identifiers per chunk, and it dies on reshares. Real permissions come from groups, nested groups, roles, folder and site inheritance, sharing links, and deny rules layered over all of it. Two representations survive contact with that.

Denormalise: expand the ACL at ingestion into a flat set of principal identifiers stored on each chunk, and expand the user's group membership at query time into the matching set. The filter is a set overlap, which every serious store can do quickly. The price is write amplification — resharing a folder rewrites the metadata of every chunk beneath it, and a change to a group with wide reach rewrites a large fraction of the index.

Or indirect: store on each chunk a small identifier for its access-control entry, resolve at query time which of those identifiers the user can satisfy, and filter on that resolved set. Reshares now touch one row in the authorisation store instead of sixty thousand in the index, and the cost moves to the query path, where you must compute the user's permitted identifier set fast enough to sit in a request. That set is cacheable per user with a short lifetime, and the length of that lifetime is your revocation lag, which you should state as a number rather than discover later.

Deny rules do not fit either scheme naturally, because set overlap is monotone — adding a principal can only widen access — and a deny is non-monotone. Resolve denials during expansion so that the permitted set you pass to the store is already net of them, or carry a separate exclusion filter that is applied conjunctively. What you must not do is resolve them after retrieval, which puts you back in the post-filter world for the subset of cases most likely to be sensitive.

Defence in depth, and the one layer that is not a defence

Filtering in the query is the control. A second, independent check immediately before the passages enter the prompt is worth having anyway: for each chunk about to be used, confirm authorisation against the live source of truth. It catches denormalisation staleness, it catches a bug in filter construction, and it is cheap because the list is short by then. Log the mismatches, because a non-zero rate is telling you your index is out of step with reality and you would otherwise never know.

The layer that is not a defence is the prompt. Instructing a model not to reveal documents it was given, or asking it to check permissions itself, is not access control — the content is in the context window, the instruction is advisory, and the mechanism is one persuasive user message from being irrelevant. The same applies to the surrounding machinery: unauthorised text must not reach query rewriting, few-shot exemplars, summarisation caches, or an embedding you compute over the retrieved set. If a document was not permitted, it should never have been fetched, and the model should never have had the opportunity to be discreet about it.

What still leaks when you have done all of this

Say this unprompted, because it is what a security reviewer will ask. Existence can still be inferred from behaviour: latency, refusal wording, whether a suggestion or autocomplete draws on a wider corpus than retrieval does. Aggregates computed over the unfiltered index — popularity, related-document graphs, trending queries — leak membership if exposed. Any model fine-tuned on the corpus has absorbed content that no query-time filter can retract, which is the strongest argument for keeping permissioned content in retrieval and out of training. And an answer already generated and delivered cannot be recalled, so the freshness of your permission data is not a hygiene concern but the boundary of the guarantee you are offering.

If a user with access to two per cent of the corpus gets visibly worse answers than a user with access to everything, your permission check is in the wrong place — and that symptom will show up long before the leak does.

Likely follow-ups

  • A folder containing sixty thousand documents is reshared. What does your ACL representation cost you?
  • How do you keep filtered approximate search from losing recall when the filter is highly selective?
  • Where do deny rules live, given that a set-overlap filter cannot express them?
  • How would you test that a user can never retrieve a document they cannot open in the source system?

Related questions

authorisationrag-securityaclfiltered-searchpermissions