SRH-10Required
Filter search results by access inside the query
Put the user's access filter into every keyword and vector search query itself. Hits, totals, facets, suggestions, excerpts and chunks sent to a model then come only from records the user may see.
How to do it
Add the account or owner condition next to the match condition, in the same WHERE clause or in the search engine's filter. Build that filter on the server from the session, never from a request field. Filtering hits after the search leaks through totals, facets and autocomplete, and breaks paging. Store the account ID and the owner, team or group list on every document and chunk in a separate index. Then the filter works there too. Update the stored rights on every permission change event. Rights change faster than indexes, so check the remaining hits against the source system before you show them or send them to a model. In PostgreSQL, the row-level security from Keep each customer's data apart applies to full-text queries as well. Each record a user opens from the results still needs the check in Check ownership on every record. Never ask a model to leave out what the user may not read, because it cannot be trusted to. Database rows stay apart by tenant as Keep files and search indexes apart by tenant describes.
Examples
JavaScript example
// The account comes from the session, never from the request body
const hits = await db.many(
`SELECT id, name FROM products
WHERE account_id = $1 AND search_text @@ websearch_to_tsquery('english', $2)
LIMIT 20`,
[session.accountId, query]);
Python example
# The account comes from the session, never from the request body
cursor.execute(
"""SELECT id, name FROM products
WHERE account_id = %s AND search_text @@ websearch_to_tsquery('english', %s)
LIMIT 20""",
(session.account_id, query))
TypeScript example
const candidates = await index.search(question, { filter: { groups: { anyOf: user.groups } }, limit: 20 });
const allowed = await documents.filterReadable(candidates.map(c => c.documentId), user);
const hits = candidates.filter(c => allowed.has(c.documentId));
How to check
Create two test accounts, each with a record holding a made-up word only it contains. Signed in as account A, search for account B's word. The answer has 0 hits, a total of 0, no suggestion and no facet count above 0. Give a test user access to a document, search for it, then remove the access. The next search returns nothing from that document, even before the index updates.
Sources
Related rules
- DAT-69 Keep each tenant's files under its own prefix in Data and databases
- DEN-31 Filter shared dashboards by customer inside the warehouse in Data engineering
- DAT-7 Keep each customer's data apart in Data and databases
- ACC-77 Check ownership on every record in Accounts and identity
For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/search/access-and-limits/search-access-filter">Agent Wranglers search playbook</a>. In code you write, add a comment next to it with the same link.