Skip to content

fix(rag): scope per-collection embedding resolution to the organization - #1425

Merged
DEENUU1 merged 4 commits into
mainfrom
fix/913-per-collection-org-scope
Sep 5, 2026
Merged

fix(rag): scope per-collection embedding resolution to the organization#1425
DEENUU1 merged 4 commits into
mainfrom
fix/913-per-collection-org-scope

Conversation

@OchnikBartek

Copy link
Copy Markdown
Member

What this fixes

knowledge_bases.collection_name is indexed but not unique — two
organizations can name a collection the same string and share one vector table.
embeddings_for_collection resolved the knowledge base by name alone
(get_by_collection_name(...).first()), returning an arbitrary tenant's row. So
an embedding call for org A could resolve org B's knowledge base: unsealing
B's vault key under B's scope, billing B, and running A's text through B's
credential (#913). It affects ingestion and search alike, and predates the
rerank work the reviewer raised it against.

The fix — resolve within the caller's organization

  • knowledge_base_repo.get_for_collection(db, name, organization_id) — the
    org-scoped lookup: the caller's own row first, then an app-scoped
    (deployment-wide) one, and never a third tenant's. The worker's
    _knowledge_base_for held the same two-pass logic and now delegates to it, so
    the tenant-narrowing rule lives in one place.
  • embeddings_for_collection(name, organization_id=None) takes the org. The
    resolver interface is now (str, UUID | None) and the store threads it:
    search/get_collection_info_for_collection → resolver. The agent search
    passes deps.organization_id; the route search and the …/info route pass
    ctx.organization_id; ingestion binds the flow's own org in the resolver
    closure (the store passes none on the insert path). None keeps the old
    first-match for a caller with no organization in hand — a local-directory sync.

The adversarial self-review (the automated reviewer is off, #311) flagged
get_collection_info as the one metadata path still resolving by name alone —
it unsealed a foreign tenant's key (for dim only; no billing, nothing
returned). It is threaded here too. The remaining org-None path is the
app-admin-only local-directory sync, unchanged from before.

Verification

  • Two-tenant integration test (the crux): org A and org B share a
    collection_name; each resolves its own knowledge base, a third org gets
    None rather than a foreign row, an app-scoped row is the fallback, and the
    embedding resolver never unseals another tenant's vault key.
  • The resolver's unit tests re-pointed at the org-scoped lookup;
    embedding_resolution.py stays at 100%. make lint-backend green.

Scope — what this does not close

Closes #913

`collection_name` on `knowledge_bases` is indexed but not unique: two
organizations can name a collection the same string and share one vector
table. `embeddings_for_collection` resolved the knowledge base with
`get_by_collection_name(...).first()` - an arbitrary tenant's row - so an
embedding call for org A could resolve org B's knowledge base, unseal B's
vault key under B's own scope and bill B, and run A's text through B's
credential (#913). It applies to ingestion and search alike, and predates
the rerank work the reviewer raised it against.

Resolve within the organization the embedding is for:

- `knowledge_base_repo.get_for_collection(db, name, organization_id)` is
  the org-scoped lookup - the caller's own row, then an app-scoped one,
  never a third tenant's. The worker's `_knowledge_base_for` carried the
  same two-pass logic and now delegates to it, so the rule lives once.
- `embeddings_for_collection(name, organization_id=None)` takes the org;
  the store threads it (`EmbeddingResolver` is now `(str, UUID | None)`,
  `search`/`get_collection_info` -> `_for_collection` -> resolver). Search,
  the collection-info route and the route search all pass the caller's org
  (`deps.organization_id` for an agent, `ctx.organization_id` for a route);
  ingestion binds the flow's org in the resolver closure, the store passing
  none on the insert path. `None` stays first-match for a caller with no
  organization in hand - a local-directory sync.

The physical vector table is still keyed by `collection_name`, so two
tenants sharing a name still share a table - the store-level isolation
question #913 flags separately, which this does not close.

Verified with a two-tenant integration test proving each organization
resolves its own key and never the other's, and the resolver's unit tests
re-pointed at the org-scoped lookup. The rerank resolver #913 also names
does not exist in the tree yet (unmerged #911); its identical fix belongs
there, on the same `get_for_collection`.

Closes #913
@OchnikBartek OchnikBartek added bug Something isn't working severity:high Real bug on a reachable path, security weakness, or resource leak security Authorization, secrets, or an internet-facing surface labels Sep 4, 2026
@OchnikBartek OchnikBartek self-assigned this Sep 4, 2026
@OchnikBartek
OchnikBartek requested a review from DEENUU1 September 4, 2026 23:23
@OchnikBartek

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8354aeb6f8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread backend/app/services/rag/vectorstore.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Security Review

Here are some automated security review suggestions for this pull request.

Reviewed commit: 8354aeb6f8

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread backend/app/repositories/knowledge_base.py
OchnikBartek and others added 3 commits September 5, 2026 01:39
`_for_collection` now calls its resolver `(name, organization_id)` (#913);
the integration stubs `_no_resolution` and `_no_collection_of_its_own` still
took one argument, so `_for_collection` calling them with two raised
`TypeError` under a real database - a path the local run cannot reach, so CI
caught it. Both now accept the organization and ignore it, as they ignore the
name.

Refs #913
Scoping per-collection resolution to the caller's organization (#913) is
only safe if every knowledge base on one collection name agrees on how it
embeds. Nothing made them agree. `collection_name` is not unique and one
name is one physical table, so two rows could index the same vectors at
different widths - pgvector then refuses the comparison outright - or at
the same width with different models, where it ranks one embedding space
against another and answers with plausible nonsense. The credential had
the same shape of problem: whichever sibling the resolver happened to read
is the vault key that got billed, so an older private row's key could pay
for chunks uploaded through a newer shared one.

A row created against a name that already exists now adopts that
collection's model, width, provider and vault key, and a caller who named
a *different* one is refused rather than silently overridden - being given
another embedding space with nothing on screen to say so is the worse
half. Which sibling a per-collection lookup picks then stops mattering,
which is what makes resolving one by organization correct rather than
merely narrower.

Cross-organization sharing was already refused at claim time (#367), so
this closes the same-organization half of it. No backfill: a deployment
with rows predating the invariant would need one, and there are none.

Verified with tests/test_shared_collection_embedding.py - adoption of each
field, the oldest holder winning, a disagreeing choice refused per field,
a restating choice accepted, and a first row on a free name keeping the
caller's own.

Refs #913
@DEENUU1
DEENUU1 merged commit 562d2bc into main Sep 5, 2026
14 checks passed
@DEENUU1
DEENUU1 deleted the fix/913-per-collection-org-scope branch September 5, 2026 15:00
@DEENUU1 DEENUU1 mentioned this pull request Sep 5, 2026
DEENUU1 added a commit that referenced this pull request Sep 5, 2026
Release 0.0.367.

Ships #1425 — per-collection embedding resolution scoped to the
organization, and the invariant that makes it correct: one collection
name is one embedding space.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working security Authorization, secrets, or an internet-facing surface severity:high Real bug on a reachable path, security weakness, or resource leak

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rag: per-collection resolution picks a KB by non-unique collection_name

2 participants