Three places a filter can live
Every AI data room applies a permission filter somewhere. Vendors describe all three the same way, so the wording tells you nothing and the layer tells you everything.
- Inside the retrieval queryenforced
The permission check is a predicate in the WHERE clause. An unauthorised chunk is never selected, so there is no downstream code path that could return it.
- After retrieval, in application codebypassable
The rows were already read. Every new caller, every error branch, every log line and every cache is a place the unfiltered result can escape through.
- As an instruction in the promptbypassable
The restricted text is already in the context window. Asking a probabilistic system not to use it is a preference, not a boundary.
The distinction is not theoretical. In July 2026 we found the second failure in our own system: a signed-in user with no relationship to a room called the retrieval function with another participant's identifier and received 30 chunks across 13 documents, containing verbatim deal text up to 1,952 characters. The in-query predicate was correct. The identifier it trusted was not, because the function could be called directly by any authenticated role. Both halves have to hold.
Method
The subject is rag_vector_search, the function every chat answer retrieves through. Its permission logic is two predicates in one WHERE clause:
WHERE c.tenant_id = p_tenant_id AND d.deleted_at IS NULL AND d.status = 'ready' AND (d.ai_chat_enabled IS NOT FALSE) AND (1 - (c.embedding_voyage <=> p_query_embedding)) >= p_similarity_threshold AND participant_can_access_document(p_participant_id, c.document_id) ORDER BY c.embedding_voyage <=> p_query_embedding LIMIT p_limit;
participant_can_access_documentresolves the participant's group defaults, walks up the folder tree for inherited folder permissions, then applies any document-level override, and returns true only at view level or above. The tenant predicate is separate and deliberate: it holds even if the permission resolver is wrong.
- QuestionParticipant identity comes from the session, never the request body
- EmbedQuery vector only, no document content
- RetrieveTenant and participant predicates inside the SQL
- AnswerModel sees authorised chunks only
- CitePage or cell range on every claim
Three adversarial cases, and one more the July incident added:
- The room's own participant asks a question. Baseline.
- A participant of a different room in the same tenant, substituted into the same call.
- A participant of a different tenant entirely.
- The published anonymous key, calling the function directly over the REST interface with no session at all.
Reproducing it
Case four needs nothing but the public key that ships in the browser bundle, so anyone can run it against us:
node --experimental-strip-types tests/security/org-isolation.test.ts node scripts/security/check-function-privileges.mjs --require-db
The first sends a zero vector and a nil UUID straight at the REST interface and counts only SQLSTATE 42501as a pass, so an ambiguous error is scored as a failure rather than quietly counted as a win. The second reads the deployed function's source out of pg_proc and fails if either predicate is missing from the text, or if anon or authenticated has regained execute permission. Both run in CI on every push.
Results
| Criterion | Result | Enforced by | How verified |
|---|---|---|---|
| Room's own participant | Up to 30 chunks | Retrieval limit | Product behaviour |
| Participant of another room | 0 chunks | Permission predicate in the query | Source assertion in CI |
| Participant of another tenant | 0 chunks | Tenant predicate in the query | Source assertion in CI |
| Published anonymous key | Call refused, 42501 | Execute grant revoked | Live probe, 13 of 13 passing |
| Same call before July 2026 | 30 chunks, 13 documents | Nothing | The incident that prompted the lockdown |
The last row is the one worth reading twice. The predicate that makes rows two and three zero was already in the query when the incident happened. It was not enough on its own, because the caller controlled the participant identifier the predicate was checking. The fix was to revoke direct execute permission from every client-reachable role, so the only way to reach the function is through a server that derives the participant from the session.
One consequence worth naming. Because the fence is a predicate rather than a post-filter, an answer can only cite what the participant could already open, which is why every citation is checkable by the person who received it. That is covered in the citations piece.
Related: What a citation has to point at · How we test our own data room · Do watermarks survive screenshots · Pricing
Permission-fenced AI with cited answers is included in every paid tier rather than sold as an add-on. See the tiers.