Security / Retrieval
Retrieval Strips the Permissions
Copy a wiki into a vector index and the rule about who may read each page stays behind. Here is how that shows an intern an HR page, how to copy the whole rule at sync time, and what filtering by the asker costs in recall.
Zero. That is how many rows in the new vector index carry a rule about who may read them. A sync job copied the company’s Confluence wiki into Postgres so an internal chatbot could answer from it, logging in as a service account that reads every page. It wrote text and an embedding per chunk. Nothing copied the page’s restriction, because no column existed to put it in.
Here is what that costs. An intern asks the chatbot, “What are the salary bands for engineers?” The top hit is a page HR restricted to its own group, and the chatbot quotes it with a link. The link fails, because Confluence won’t open a restricted page for an intern. The answer already contains the numbers.
Where the rule gets lost #
Confluence checks the reader on every page view. Pages are open by default, and a page can be restricted so only certain users or groups may view it. That check sits on the path every reader takes.
The chatbot adds a second path. The sync job splits each page into chunks of a few paragraphs and turns each chunk into an embedding: a list of numbers that places similar texts near each other. Then it writes the chunks to a table:
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
page_id text,
content text,
embedding vector(384)
);
The row holds the page’s text and not its restriction. At question time the app embeds the question and asks for the five nearest chunks, where <=> is pgvector’s cosine distance operator:
SELECT content FROM chunks
ORDER BY embedding <=> $1
LIMIT 5;
The app runs this with one database login for every user, so the query has no idea who is asking. To this index, every page is public.
The citation looks fine too. The page is real, the search returned it, and it says what the answer claims, so a grounding check passes. Whether this reader may see the page is a separate question, and nothing in the pipeline asks it.
Put the reader’s groups into the query #
The fix is to copy the rule along with the text and check it in the query. The trap is copying the wrong rule. A Confluence page’s own restriction is only part of its permission. The effective view permission intersects three things: the space’s view permission, the inherited view restriction of every ancestor page or folder, and the page’s own restriction. A page with no restriction of its own is still closed if its space is limited to HR, or if a parent carries a view restriction. Read restrictions alone and you mark both of those open.
Each level is a list of users and groups, and the reader must match every level. That is an AND of ORs, and one flat list can’t hold it. Row four in the table below shows what leaks when you try. So keep one list per level, stored per page, since a page’s chunks share its rule:
CREATE TABLE page_acl (
page_id text NOT NULL,
level text NOT NULL, -- 'space', 'page' or 'ancestor:<id>'
principals text[] NOT NULL,
PRIMARY KEY (page_id, level)
);
Principals rather than groups, because a Confluence restriction can name individual users too. An open ancestor adds no row. Write all of a page’s rows in one transaction, so no reader sees half a rule. Then check every level in the same query that ranks:
SELECT c.content FROM chunks c
WHERE (SELECT bool_and(a.principals && $2) -- $2 = the asker's own id and their groups
FROM page_acl a WHERE a.page_id = c.page_id)
ORDER BY c.embedding <=> $1
LIMIT 5;
&& is Postgres’s array overlap operator, true when two arrays share an element. bool_and is true only when every level overlaps, which supplies the AND. Pass the user’s account id alongside their groups, as user:a1b2 next to group:interns. Here is how five pages fare for two readers:
| Page | page_acl rows | Intern {group:everyone, group:interns, user:i77} | HR manager {group:everyone, group:hr, user:m12} |
|---|---|---|---|
| Holiday policy, open space | space {group:everyone} | shown | shown |
| Salary bands, restricted to HR | space {group:everyone}, page {group:hr} | hidden | shown |
| Onboarding checklist, no restriction of its own, in a folder restricted to HR | space {group:everyone}, ancestor {group:hr} | hidden | shown |
| Reorg plan, restricted to HR, in the leadership-only space | space {group:leadership}, page {group:hr} | hidden | hidden |
| New page, permissions not synced yet | none | hidden | hidden |
Row three is what a sync reading page restrictions alone gets wrong: the page carries none, so it looks open. Row four is what a flat list gets wrong. Stored as {group:hr}, it reaches the HR manager, who isn’t in leadership and couldn’t open it in Confluence. Row five is the fail-closed case. bool_and over no rows is NULL, and NULL hides the row, so a page whose permissions haven’t synced is visible to no one. That failure produces a page nobody can find, and someone will report it. The opposite failure is a leak, and nobody reports those.
Expanding every group into user ids at sync time would also be exact, but a page open to the whole company would then carry every employee’s id, and every group change would rewrite rows. Per-level lists keep groups as groups, at the price of one small lookup per candidate row.
Filter before the model reads anything. Dropping a citation afterwards doesn’t help, because the model has already read the passage and a paraphrase carries no source ID.
The filter can quietly return fewer than five rows #
With a large table you will add an approximate index, usually HNSW, so search doesn’t compare the question with every row. HNSW is a graph of vectors that finds near neighbours without visiting them all. Its operator class has to match the distance operator, so <=> needs vector_cosine_ops:
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
The pgvector README spells out the catch: with approximate indexes, filtering is applied after the index is scanned. The index hands back a candidate list first, sized by hnsw.ef_search, 40 by default. The WHERE clause then runs on those 40. If the intern may read 10% of chunks, about 4 survive on average, so a query asking for 5 rows usually gets fewer:
| Share of chunks the user may read | Expected rows left from 40 candidates | Searches that return no rows |
|---|---|---|
| 50% | 20 | 0% |
| 10% | 4 | 1.5% |
| 2% | 0.8 | 45% |
At 2%, about 45% of searches return nothing and almost none return five. The answer gets worse and nothing errors. Since pgvector 0.8.0 you can turn on iterative index scans, which keep scanning until enough rows pass the filter. Behind a connection pool, use SET LOCAL inside the request’s transaction, so the setting ends with it:
SET LOCAL hnsw.iterative_scan = relaxed_order; -- or strict_order
SET LOCAL hnsw.max_scan_tuples = 20000; -- the default cap on how far it scans
relaxed_order may return results slightly out of distance order, and strict_order keeps exact order. For IVFFlat the settings are ivfflat.iterative_scan, which accepts only relaxed_order, and ivfflat.max_probes.
That table is optimistic. It assumes the chunks a user may read are spread evenly through the index, and they are not. Permissions track topic. Restricted pages sit close together in embedding space, and the question most likely to land among them is the one about salaries. For that question, far fewer than 4 of the 40 candidates survive. Measure recall on the questions that sit next to restricted content, not on average traffic.
Check which plan you got, too. The planner may decide an exact scan is cheaper and skip the HNSW index, changing both recall and latency. Run EXPLAIN ANALYZE with a real user’s principals and read which index the plan names. Fix a short result by scanning further, never by loosening the filter.
Let Postgres enforce it with row-level security #
A WHERE clause in application code is one forgotten line away from a leak. Postgres can attach the rule to the table instead, with row-level security (RLS):
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY readers ON chunks FOR SELECT
USING ((SELECT bool_and(a.principals && coalesce(
nullif(current_setting('app.principals', true), ''), '{}')::text[])
FROM page_acl a WHERE a.page_id = chunks.page_id));
GRANT SELECT ON chunks, page_acl TO chatbot;
At the start of each request’s transaction, the app runs SELECT set_config('app.principals', $1, true) with a value such as {group:everyone,group:interns,user:i77}. The final true limits the setting to that transaction, and Postgres accepts any two-part setting name like this one.
The coalesce and nullif are not decoration. On a fresh connection an unset parameter reads as NULL, and NULL overlaps nothing, so the policy hides everything. On a pooled connection that set the value in an earlier transaction, the same call returns an empty string, and ''::text[] raises “malformed array literal”. That still fails closed, because a thrown query returns no rows, though it fails with a 500 rather than an empty result. Folding both cases into '{}' makes the behaviour the same either way.
The policy’s subquery runs with the privileges of the role that asks, which is why the chatbot’s role needs SELECT on page_acl too.
Three documented rules decide whether RLS protects you:
- With RLS on and no policy, Postgres denies every row.
- Superusers and roles with
BYPASSRLSalways skip policies. - Table owners skip them too, unless you run
ALTER TABLE chunks FORCE ROW LEVEL SECURITY.
So the chatbot must not connect as the role that owns the table. The policy also filters the rows the index returns, so the iterative-scan setting matters just as much.
Then write the test that proves it. Load the five pages from the table, run the query as each reader, and assert each sees exactly its column. The reorg plan, asked for by the HR manager, is the case a flat list leaks. Put it in CI beside the migrations.
Stale permissions and other customers #
Two clocks run behind this design, and they get confused for each other.
The first is document-permission lag, set by the sync interval. A page gets restricted, or moves into a restricted space or folder, and until the next sync the index still lists the old principals. Only tightening changes leak, so sync those on an event rather than a timer. Subscribe to Confluence’s permission and move events: content_permissions_updated, space_permissions_updated and page_moved, or their Forge equivalents. A change to a parent or a space reaches every page under it, so re-resolve the whole subtree, and let the periodic job be the backstop. Suppose permissions tighten 24 times a day and the sync runs hourly. Each change waits half an hour on average, so the index answers from a stale rule for about 12 page-hours a day. A five-minute sync cuts that to about one.
The second is group-membership lag, and the index plays no part in it, because page_acl stores groups, not their members. Someone leaves HR, but their next request still carries group:hr in $2 until the session token expires or the group cache refreshes. The token lifetime sets that window, so a one-hour token means up to an hour of a former HR member reading HR pages. Write both numbers down and give each a limit.
Separate customers deserve better than a shared index with a tenant column, because a leak between companies is the one that ends contracts. In Pinecone, give each tenant its own namespace: every query targets a single namespace, and each is stored separately. In Postgres, give each tenant its own list partition or table, which is what the pgvector README recommends for tenant isolation.
Recall pushes the same way. Across 1,000 tenants each holds about 0.1% of the rows, so a post-filter on 40 candidates leaves 0.04 of a row. To find five, a shared index would have to visit about 5,000 candidates per query, even with each tenant’s rows spread evenly. A per-tenant partition has its own HNSW graph, so when the query names the tenant, all 40 candidates already belong to that tenant. One change fixes the isolation and the recall together.
What Microsoft 365 Copilot shows #
Big products do filter by the user. Microsoft documents that Microsoft 365 Copilot, now named Microsoft Copilot, “only surfaces organizational data to which individual users have at least view permissions”.
Its documented failure is the one left over: oversharing. A SharePoint site shared with “Everyone except external users” is readable by every employee, so Copilot can surface it to every employee. Microsoft’s deployment guidance opens with a step called “Remediate oversharing”, which has admins find overshared sites, company-wide sharing links and broken permission inheritance. Meanwhile, Restricted Content Discovery can keep sensitive sites out of Copilot and organization-wide search, as a temporary control that changes no permissions.
A filter is only as strict as the permissions it copies. Confluence pages are open by default, so check what is already open before you index it.
Every other copy #
The index isn’t the only copy. A response cache shared across users can hand one user’s answer to another, so its key must include the asker’s principals. Traces store the passages each answer used, so treat inference logs as a sensitive store. And you can skip the copy entirely: keep only chunk IDs in the index and fetch the text from Confluence as the user, so the source decides and a revoked page stays closed. That fetch is a post-filter too, so ask the index for more IDs than you need and keep fetching until enough pass. The index can still reveal that a matching page exists. What the output does next, such as rendering a link or calling a tool, needs its own check in code after the model.
What the copy leaves behind #
A vector index is a copy of your documents with one field missing. Every fix here is the same move at a different layer: copy the whole rule when you sync, store it beside the text, and make the database check every level against the asker. After that, the number to watch is the lag you accept. How long does a newly restricted page stay open in the index, and how long does a former HR member’s token keep saying group:hr? Those two answers are your real access-control policy.
Sources and method
- pgvector’s filtering behaviour, operator classes, iterative index scans (added in 0.8.0) and multitenancy advice come from the pgvector README. Row-level security, custom settings and bool_and behaviour come from the PostgreSQL documentation.
- Confluence permissions and event names come from Atlassian’s Confluence Cloud, Connect and Forge documentation, and Pinecone namespaces from Pinecone’s multitenancy guide. Microsoft 365 Copilot’s permission behaviour, its oversharing guidance and Restricted Content Discovery come from Microsoft Learn. All as of October 2026.
- The SQL, including the policy and the five-page table, was run against Postgres 16 and 17 with pgvector 0.8.6 and 0.8.7.
- The intern, the pages, the sync-lag figures and the recall arithmetic are a hypothetical example with round numbers. The share of empty searches assumes readable chunks are spread evenly.