{"slug": "enforce-permissions-inside-the-pgvector-query-not-after-it", "title": "Enforce Permissions Inside the pgvector Query, Not After It", "summary": "A developer has detailed a permission-aware retrieval-augmented generation (RAG) pattern that pushes access-control filtering inside the pgvector query rather than applying it after retrieval. The approach, implemented in a project called vaultrag, uses a common table expression named \"visible\" that joins chunks to documents and checks an ACL table via EXISTS before the nearest-neighbor ORDER BY and LIMIT run, so unauthorized chunks are never selected, ranked, or counted. The developer argues the common post-filter pattern causes both a correctness bug — returning fewer than top-k results when forbidden chunks occupy the nearest ranks — and a leak surface across logging, metrics, caching, and debug paths that touch pre-filter rows.", "body_md": "Most permission bugs in RAG systems look harmless in review. You retrieve the top-k nearest chunks, then you drop the ones the user is not allowed to see. It reads as correct. It is not, and the failure is quiet.\n\nI want to show you why the post-filter is the wrong place, and how to push the access-control decision inside the retrieval query itself so that an unauthorized chunk is never selected, never ranked, never counted. I will ground this in a small permission-aware RAG project of mine called vaultrag, but the technique is portable to any Postgres plus pgvector stack.\n\nHere is the pattern I want you to stop writing.\n\n```\n# fetch top 10 by vector distance\nrows = fetch_nearest(query_embedding, limit=10)\n# then remove what the user cannot see\nvisible = [r for r in rows if user_can_see(r)]\n```\n\nTwo things are wrong here.\n\nThe first is a correctness bug. Your `LIMIT 10` runs against every chunk in the table. If eight of the ten nearest chunks belong to documents this user cannot read, you filter them out and hand back two results. The user experiences this as a broken search, not as a security boundary. Relevant material they are allowed to see sat at rank 11 and never made it into the candidate set.\n\nThe second is worse, and it is the reason to care. Every code path that touches the raw result set is now a place a leak can happen. Logging the pre-filter rows, a metrics counter, a debug endpoint, a caching layer that memoizes by query text: each one can see chunks the user cannot. The filter protects exactly one exit. Everything upstream of it is holding forbidden data.\n\nThe fix is to make it structurally impossible to hold that data in the first place.\n\nStart with an explicit access-control list per document. In vaultrag a document has an ACL made of principals, where a principal is either a user id or a group name.\n\n```\nCREATE TABLE documents (\n    id          TEXT PRIMARY KEY,\n    title       TEXT NOT NULL,\n    deleted_at  TIMESTAMPTZ\n);\n\nCREATE TABLE doc_acl (\n    doc_id      TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,\n    principal   TEXT NOT NULL\n);\n\nCREATE TABLE chunks (\n    id          BIGSERIAL PRIMARY KEY,\n    doc_id      TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,\n    text        TEXT NOT NULL,\n    embedding   VECTOR(1536)\n);\n```\n\nThe critical design choice is resolving the caller's principals on the server, never from the request. If a client can assert its own group membership, the ACL is decorative.\n\n``` python\nasync def resolve_principal(conn, user_id: str):\n    async with conn.cursor(row_factory=dict_row) as cur:\n        await cur.execute(\"SELECT id, groups FROM users WHERE id = %s\", (user_id,))\n        row = await cur.fetchone()\n    if row is None:\n        return None\n    # principals we match against: the user id plus every group they belong to\n    return [row[\"id\"], *(row[\"groups\"] or ())]\n```\n\nThe technique is a single common table expression, `visible`, that defines the universe of chunks this caller may see. Every other part of the query reads from `visible`, not from `chunks`. There is no path that starts anywhere else.\n\n```\nWITH visible AS (\n    SELECT c.id, c.doc_id, c.text, c.embedding\n    FROM chunks c\n    JOIN documents d ON d.id = c.doc_id\n    WHERE d.deleted_at IS NULL\n      AND EXISTS (\n          SELECT 1 FROM doc_acl a\n          WHERE a.doc_id = d.id\n            AND a.principal = ANY(%(principals)s)\n      )\n),\nranked AS (\n    SELECT id, doc_id, text\n    FROM visible\n    WHERE embedding IS NOT NULL\n    ORDER BY embedding <=> %(embedding)s::vector\n    LIMIT %(limit)s\n)\nSELECT * FROM ranked;\n```\n\nRead the order of operations, because it is the whole point. The `EXISTS` against `doc_acl` runs before the `ORDER BY ... <=> ...`. The nearest-neighbor ranking and the `LIMIT` operate on `visible`, which already excludes forbidden chunks. Your top-k is now the top-k of what the user is allowed to see. The correctness bug from earlier is gone, and so is the leak surface, because the query planner never materializes a forbidden row into the candidate set.\n\nA detail worth stealing: use `EXISTS` rather than a plain `JOIN doc_acl`. A document with three matching ACL rows would otherwise multiply into three copies of each chunk. `EXISTS` short-circuits on the first match, so each visible chunk appears exactly once.\n\nThe same `visible` CTE composes cleanly with hybrid search. In vaultrag both the vector arm and the full-text arm select from `visible` before they are fused with reciprocal rank fusion, so neither arm can surface a chunk the other could not.\n\n```\nWITH visible AS ( ... ),                 -- the boundary, defined once\nvec AS (\n    SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> %(embedding)s::vector) AS rank\n    FROM visible WHERE embedding IS NOT NULL\n    ORDER BY embedding <=> %(embedding)s::vector LIMIT %(candidates)s\n),\nkw AS (\n    SELECT id, ROW_NUMBER() OVER (\n        ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('english', %(q)s)) DESC) AS rank\n    FROM visible WHERE tsv @@ websearch_to_tsquery('english', %(q)s)\n    LIMIT %(candidates)s\n)\n-- fuse vec and kw, both already scoped to visible\n```\n\nBecause both arms read from `visible`, there is no code path in the function that can rank an unauthorized chunk. That is a property you can state about the query, not a behavior you hope the filter enforces.\n\nPushing the ACL into the query moves the boundary, it does not make the boundary free. The `EXISTS` subquery runs per candidate document, and on a large table the planner's choices matter. You want an index that makes the ACL check cheap:\n\n```\nCREATE INDEX ON doc_acl (doc_id, principal);\n```\n\nThere is real tension here with the pgvector index. An HNSW or IVFFlat index gives you approximate nearest neighbors fast, but the approximation happens before your `WHERE` filter is applied inside the scan. When a filter is very selective, meaning the user can see only a tiny fraction of documents, the vector index can return a page of candidates that are then almost entirely filtered out, and you get fewer results than your `LIMIT` asked for. This is the well-known filtered-search problem. Mitigations exist: raise `hnsw.ef_search`, use partitioning, or on recent pgvector use iterative index scans. Measure it on your data. Do not assume the CTE is free just because it is correct.\n\nCorrectness and security both improve when the permission check is a precondition of ranking rather than a cleanup step after it. Define the visible set once, in a CTE, and make every arm of your search read from it. The guarantee you get is structural: an unauthorized chunk is not filtered out late, it is never selected.\n\nIf you want a full working reference, the retrieval query, the ACL schema, and the server-side principal resolution live in my project at [github.com/AgentPostmortem/vaultrag](https://github.com/AgentPostmortem/vaultrag). Borrow the CTE and adapt the ACL model to your own tenancy rules.", "url": "https://wpnews.pro/news/enforce-permissions-inside-the-pgvector-query-not-after-it", "canonical_source": "https://dev.to/royalpinto007/enforce-permissions-inside-the-pgvector-query-not-after-it-439", "published_at": "2026-09-23 09:30:32+00:00", "updated_at": "2026-09-23 09:58:36.468506+00:00", "lang": "en", "topics": ["ai-infrastructure", "mlops", "ai-safety"], "entities": ["pgvector", "Postgres", "vaultrag"], "alternates": {"html": "https://wpnews.pro/news/enforce-permissions-inside-the-pgvector-query-not-after-it", "markdown": "https://wpnews.pro/news/enforce-permissions-inside-the-pgvector-query-not-after-it.md", "text": "https://wpnews.pro/news/enforce-permissions-inside-the-pgvector-query-not-after-it.txt", "jsonld": "https://wpnews.pro/news/enforce-permissions-inside-the-pgvector-query-not-after-it.jsonld"}}