SIGN IN SIGN UP

🔒 fix: scope every document read and delete by owner (#319)

* 🔒 fix: scope every document read and delete by owner

Six routes addressed the vector store by caller-supplied `file_id` alone, or
authorized a whole result set from the first hit the search returned.

`GET /ids` listed every file id in the deployment and `POST /query_multiple`
performed no authorization at all, so the two together disclosed the content of
every file to any authenticated caller. `POST /query` authorized its whole
result set from `documents[0]` — the first *returned* hit — so any hit behind it
was never checked; a `file_id` is chosen by whoever uploads, so an attacker's own
row ranking first authorized the rows behind it. `GET /documents`,
`GET /documents/{id}/context` and `DELETE /documents` read or deleted the chunks
of any file id the caller could name. A chunk with no recorded `user_id` read as
"belongs to everyone". On the synchronous store path a failed ingestion rolled
back by `file_id` alone, so an upload under someone else's file id destroyed
their chunks; the async pgvector pipeline already scopes its rollback to the
ingestion attempt and is left as it is.

Owner scope is now resolved from the verified token in one place — `app/scope.py`
— and pushed into the store query before ranking, so a foreign chunk is never
read into the process rather than being filtered out after the fact. The scope
arguments are required rather than optional on both stores, so a new call site
cannot skip them by omission, and the pgvector queries carry the collection
clause as well: `langchain_pg_embedding` is shared by every collection, so a
lookup that omitted it could read rows this store does not serve.

A file outside the caller's scope answers "not found" rather than "found but
refused", so none of these routes is an existence oracle.

`entity_id` is unchanged in reach and still caller-asserted. It now *widens* the
owner set rather than replacing the caller's identity — agent knowledge bases are
owned by an agent id, so that widening is what keeps them readable — but nothing
in a token minted today proves the caller may act for the entity it names.
Closing that requires the token to carry the entity authorization, a coordinated
change with the callers that mint those tokens, and is tracked separately.
`tests/test_authorization.py::test_entity_id_is_still_caller_asserted` asserts
the residual so that closing it is a deliberate edit.

Breaking: chunks with no `user_id` are owned by nobody and are no longer
readable; README carries the backfill statement. `atlas-mongo` deployments must
add `user_id` as a filter field to the vector search index before deploying.

* 🛡️ fix: validate before deleting, and cover the atlas-mongo predicate

Self-review of the previous commit turned up a defect it introduced and a path
it left untested.

`DELETE /documents` resolved existence and then deleted before checking the
result, so a request naming one owned file and one unknown id destroyed the
owned file's chunks and still answered 404. A 404 reads as "nothing happened",
so a caller that treats it as already-deleted — which both of LibreChat's delete
paths do — loses content it never asked to remove. The existence check now runs
before anything is deleted. This is the same defect #304 reported against the
original route; the note closing that PR claimed this one avoided it
structurally, which was wrong.

The atlas-mongo store had no test of its own. Its four scoped methods and the
`$vectorSearch` pre-filter are now covered against an in-memory collection that
evaluates the filter documents rather than recording them, so a predicate that
selected the wrong rows would fail rather than pass on call-argument equality.
Dropping the owner clause fails six of the eight.

Also documents a migration case the backfill statement missed: a deployment that
ran without `JWT_SECRET` records the literal owner `public` on everything written
in that period, and once a signing key is set no caller owns `public`. Routes
other than `/query` handed that content to everybody before this release — that
is the hole being closed — but operators upgrading from an open deployment need
to know it is there.
D
Danny Avila committed
f426560c59ace5fedee6c215b27e8515694252cd
Parent: 4985b37
Committed by GitHub <noreply@github.com> on 8/15/2026, 5:09:36 PM