The standard enterprise search demonstration goes like this: point an ingestion pipeline at a document store, embed everything, and let a model answer questions with citations. It works immediately, which is exactly the problem — because the version that works immediately has no concept of who is asking.
Three ways teams get this wrong
- No permission model at all. Everything is indexed, everyone can retrieve everything. This is usually described as "we will add permissions later", and later is usually after someone asks the assistant what the redundancy plan is and gets an answer.
- Permissions applied at indexing time. The index is filtered per user or per group at build time. This appears rigorous, but permissions change and indexes lag. A person who left the finance team last week can still retrieve last quarter's figures until the next full reindex.
- Permissions applied to the final answer. Retrieval is unrestricted, and a filter attempts to redact the response. The model has already read the content, and the leak now happens through summarisation, inference and follow-up questions rather than through direct quotation.
The correct boundary is query time, against the source system
Retrieval must be filtered at query time using the requesting user's current entitlements, resolved from the system that owns the document — not from a copy of its permissions taken at ingest. That means the index stores a stable reference to the source object's access-control identity, and the retrieval query joins against the caller's effective entitlements before any candidate is scored.
Concretely, in Postgres with pgvector, this means the similarity search is not a standalone vector query. It is a vector query constrained by a permission predicate that row-level security enforces regardless of what the application code asks for.
-- The RLS policy is the boundary, not the WHERE clause the app remembered to add.
create policy chunk_read on document_chunks
for select using (
exists (
select 1
from document_grants g
where g.document_id = document_chunks.document_id
and g.principal_id = any (current_effective_principals())
and g.revoked_at is null
)
);
-- The application query cannot widen this, only narrow it.
select chunk_id, content, 1 - (embedding <=> $1) as score
from document_chunks
order by embedding <=> $1
limit 40;Two consequences worth planning for
First, retrieval quality drops when the permission filter is restrictive, because the candidate pool shrinks. Retrieve a larger candidate set before reranking to compensate, and measure quality per permission profile rather than as a single aggregate number — an assistant that works brilliantly for administrators and poorly for everyone else will read as broken to most of its users.
Second, "I cannot find anything about that" and "you are not permitted to see anything about that" are different answers, and the difference itself can leak information. In most enterprise contexts the honest response is the safer one: tell the user that relevant material exists but is not available to them, and give them a route to request access. Silent absence teaches people the assistant is unreliable, which costs you the adoption you built it for.
