Agent Wranglers

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

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.