refactor(db): every read path takes a Scope — "every workspace" is passed, never defaulted (P2-1b-ii) - #406
Draft
jwvanderstam wants to merge 3 commits into
Draft
jwvanderstam wants to merge 3 commits into
jwvanderstam wants to merge 3 commits into
Conversation
…urity (P2-1b-i) Records ADR-5: workspace isolation is enforced in the database. The scope is passed to get_connection() by the method that already holds it, not bound in a contextvar; ALL_WORKSPACES stays on the owner role. The ADR states what this commits to (shared-schema tenancy as the boundary, operator = installation owner) and what it does not (a single node). - The 18 by-id methods hand their scope to get_connection(scope=). - test_object_authorization_matrix fails any scoped method that does not, and asserts the scan finds exactly SCOPED_METHODS. - Migration 0018 grants the application identity SET on localchat_scoped; without it a managed database would refuse the role switch. No behaviour change: get_connection accepts the scope and does not act on it until the 34 methods still taking workspace_id: str | None are converted (P2-1b-ii), so retrieval is covered from the first day RLS enforces anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kspace Found while sorting the workspace_id: str | None methods for P2-1b-ii. - POST /api/chat: additional_workspace_ids from the request body reached document and memory retrieval unauthorised. Any user who knew a workspace's id could retrieve from it, a workspace API key included. Each extra id is now authorised like the primary one (member at viewer+, admin any, API key none); one unauthorised id refuses the request with 403. - POST /api/documents/test: retrieval ran with no workspace, returning chunk previews from every workspace to any viewer. Now uses the authorised one. - list_documents LLM tool: called get_all_documents() bare, listing every workspace's documents. Now reads the request scope like search_documents. Each has a regression test that fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sed, never defaulted (P2-1b-ii) Fifteen database methods took workspace_id: str | None = None and read None as every workspace - the listings, the counts, and retrieval itself. Each now takes a mandatory keyword-only scope: Scope, builds SQL through scope_predicate (which refuses None and ""), and hands the scope to get_connection(scope=). The retrieval chain above them (retrieve_context, MemoryRetriever.retrieve, suggest_documents, chat's retrieve_contexts) takes a Scope too, so the translation happens once, at the route, via get_scope(). Behaviour-preserving by construction: every former None is an explicit ALL_WORKSPACES (SyncWorker, connector loading, boot count, admin stats, the two token-authenticated MCP list_sources calls). SCOPED_METHODS now lists 33. One behaviour change, a disclosure the conversion surfaced: GET /api/status counted documents for whatever X-Workspace-ID named. It now counts only a workspace the caller is authorised for, and 0 otherwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This was referenced Sep 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Second of three PRs for the application half of P2-1b (ADR-5).
What changes
Fifteen database methods took
workspace_id: str | None = Noneand readNoneas every workspace. That's the pattern P0-1 removed from the by-id paths, and it was still present in the listings, the counts and retrieval itself. Each of the 15 now:scope: Scope;scope_predicate, which refusesNoneand""at runtime;get_connection(scope=).search_similar_chunks,search_lexical_chunks,search_memoriesget_all_documents,get_document_count,get_chunk_count,list_conversations,count_conversations,list_connectors,get_all_memoriesget_stale_documents,get_low_confidence_queries,get_workspace_ontology,is_duplicate_memory,delete_all_conversationsThe retrieval chain above them takes a
Scopetoo:retrieve_context,_run_retrieval_pipeline,MemoryRetriever.retrieve,suggest_documents, and chat'sretrieve_contexts/get_rag_context/retrieve_plan_and_memory. The translation happens once, at the route, throughget_scope(request). It is not repeated asworkspace_id or ALL_WORKSPACESfurther down.Behaviour: unchanged by construction, with one deliberate exception
Every place that passed
Nonenow passes an explicitALL_WORKSPACES, where a reviewer can see it:list_sourcescalls;--workspace-id;On routes,
get_scopereturns whatget_workspace_iddid for members. For an admin who named no workspace, it returnsALL_WORKSPACESwhere the old code gotNone.The exception is a fourth disclosure, which the conversion surfaced.
GET /api/statusneeds only a session, and it counted documents for whateverX-Workspace-IDnamed: any workspace, or the whole installation when no header was sent.get_scope()refused on that route because no guard had run there, which is how it showed up. Status now runs the workspace check without requiring it to pass: an authorised caller gets their scope's count, and anyone else gets 0, so the status bar still renders. It's a regression test inTestApiStatusthat fails without the fix.Not converted, deliberately
document_existsand the five inserts take the workspace a row is written to. ThereNonemeans "no workspace", matched withIS NOT DISTINCT FROM. P2-1b-iii has to handle them because of RLS insert checks.workspaces.py/workspace_keys.pytake the workspace itself as the object being addressed.How it is held
SCOPED_METHODSnow lists 33 methods. The AST checks fail on any of these:src/ortests/that omitsscope=;scope;src/dbthat is missing from the list.The one blind spot is code held in strings.
test_list_params_survive_vector_adapters.pyruns Python through a subprocess. The static check couldn't see those calls, and the integration run caught them, so both are needed.Verification (local, Python 3.12 container + throwaway pgvector pg16, isolated from the live stack)
ruff,mypy,bandit: clean.-m "not ollama", as CI runs it): 330 passed after fixing the two string-embedded calls above; 3 skipped, as in CI.ALL_WORKSPACES;workspace_id=Xbecomesscope=X. Two memory assertions change from%s::uuidto%s, becausescope_predicateemits no cast. The by-id paths already rely on that against real Postgres. There's also one new test:_allowed_workspace_ids(None)now raises instead of reading as unscoped.Noticed, not changed
RetrievalMixin._get_app_cache()always returnsNone, so the query-result cache inretrieve_contextis dead code. Its key is(query, top_k, min_similarity, hybrid), with no scope, filters or source ids. If anyone switches it on as written, it becomes a cross-workspace leak. It should either be deleted or get the scope in its key before it's revived.🤖 Generated with Claude Code