Repository navigation
feat(db): row-level security on the workspace-owned tables (P2-1b, database half) - #400
Merged
Merged
Conversation
…tabase half)
P0-1 made the workspace scope a mandatory value and test_object_authorization_
matrix.py fails any call that omits it. P2-2b proved the routes refuse a foreign
workspace's object over HTTP. This is the third layer: the database refusing on
its own, so a query that reaches it without a scope returns nothing rather than
everything.
Migration 0017 puts one policy on ten tables — the seven carrying workspace_id
(documents, conversations, memories, answer_feedback, connectors,
workspace_api_keys, workspace_members) plus document_chunks,
conversation_messages and annotations, which borrow their parent's through an
EXISTS predicate.
The list came from information_schema against a fully migrated database rather
than from reading the DDL, and that mattered twice: chunk_stats looks like a
workspace-owned table and carries no such column, while documents, conversations
and memories only gain theirs in migration 0003 — so a grep of connection.py and
a query against an unmigrated database each give a different wrong answer.
Two mechanisms are load-bearing, both established by running them:
* set_config('app.workspace_id', %s, true), never SET LOCAL app.workspace_id.
SET is a utility statement and takes no bind parameter, so writing it means
interpolating the one value that decides what the caller can see.
* A NOLOGIN role to SET LOCAL ROLE into. RLS does not apply to a superuser or
to a table's owner, and the application connects as the owner — without a
role to switch into every policy is inert while every test of it passes.
Both are transaction-local, so a pooled connection cannot carry one request's
scope into the next.
tests/integration/test_row_level_security.py is the IVP: unscoped sees zero rows
from all ten tables, a foreign scope sees zero, the owning scope sees its own.
Proven non-vacuous by disabling RLS on documents — three tests go red, each
naming it.
That module creates and migrates a database of its own. Written against the
shared one it would have SKIPPED in CI and looked identical to a pass: the shared
database gets its schema from _ensure_extensions_and_tables(), and the Alembic
chain never reaches it — test_migrations_apply.py uses a throwaway of its own and
the integration job runs plain pytest.
The application is deliberately NOT wired to this yet, so the migration is inert
for it. RLS needs the scope per transaction and get_connection() does not know
it; scope arrives per method. The intended hook, P0-2's request_scope()
contextvar, is bound in exactly one place — api_routes.py:179, the chat SSE
stream. Hooking it there would protect the chat path alone while reading as
though the application were covered, which is worse than not wiring it. Real
coverage needs the scope bound wherever it is authorised (get_scope(), every
guarded route) plus a decision on the ALL_WORKSPACES paths. Recorded in the
ticket instead of half-built here.
Rows with a NULL workspace_id are invisible to a scoped transaction. Correct
today — migration 0003 backfilled every row — but GKB-1 specifies its global
knowledge base as exactly those rows, so it must revisit this.
Verified with pytest's own exit code: integration 329 passed / 3 skipped, fast
suite 3226 passed / 22 skipped, both exit 0; ruff, mypy and bandit clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
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.



ROADMAP P2-1b, database half. Sprint 16's last item; P2-1a shipped with P0-1.
Why a third layer
P0-1 made the workspace scope a mandatory value, and
test_object_authorization_matrix.pyfails any call that omits it. P2-2b (#399) proved the routes refuse a foreign workspace's object over HTTP. Both are the application policing itself. This is the database refusing on its own — so a query that reaches it without a scope returns nothing rather than everything.What it does
Migration
0017puts one policy on ten tables:workspace_id(7)documents,conversations,memories,answer_feedback,connectors,workspace_api_keys,workspace_membersEXISTS(3)document_chunks,conversation_messages,annotationsThe list came from
information_schemaagainst a fully migrated database rather than from reading the DDL, and that mattered twice:chunk_statslooks like a workspace-owned table and carries no such column, whiledocuments,conversationsandmemoriesonly gain theirs in migration0003. A grep ofconnection.pyand a query against an unmigrated database each give a different wrong answer.Two load-bearing mechanisms, both established by running them
set_config('app.workspace_id', %s, true), neverSET LOCAL app.workspace_id = %s.SETis a utility statement and takes no bind parameter, so the only way to write it is to interpolate the one value that decides what the caller can see.NOLOGINrole toSET LOCAL ROLEinto. RLS does not apply to a superuser or to a table's owner, and the application connects as the owner — without a role to switch into, every policy is inert while every test of it passes.Both are transaction-local, so a pooled connection cannot carry one request's scope into the next.
The IVP
tests/integration/test_row_level_security.py— 42 tests: an unscoped transaction sees zero rows from all ten tables, a foreign scope sees zero, the owning scope sees its own, and the scope does not survive aCOMMIT.Proven non-vacuous by disabling RLS on
documents: three tests go red, each naming that table.What is deliberately not here
The application is not wired to this, so the migration is inert for it.
RLS needs the scope set per transaction, and
get_connection()does not know it — scope arrives per method, asscope=on each mixin call. The intended hook was P0-2'srequest_scope()contextvar, but it is bound in exactly one place:api_routes.py:179, the chat SSE stream, read by the LLM retrieval tools. Hookingget_connection()to it today would protect the chat path and nothing else, while reading as though the whole application were covered — worse than leaving it unwired.Real coverage needs the scope bound wherever a request's scope is authorised (
get_scope(), i.e. every guarded route), plus a decision on theALL_WORKSPACESpaths — admin operations, the webhook receiver,SyncWorker— which stay on the owner role and so bypass RLS by not switching role at all. Written up in the ticket rather than half-built here.The capability exists, is tested, and enforces nothing yet. That is the safe order.
One thing GKB-1 must revisit
Rows whose
workspace_idis NULL are invisible to a scoped transaction. Correct today — migration0003backfilled every existing row — but GKB-1 specifies its global knowledge base as exactly those rows, so it will needOR workspace_id IS NULLor a policy of its own. Recorded in the migration's docstring and the ticket.Verification
Exit codes from pytest itself, not a pipeline's:
pytest tests/integration/— 329 passed, 3 skipped, exit 0 (up from 287; the 42 new tests)pytest -m "not (slow or ollama or db)"— 3226 passed, 22 skipped, exit 0tests/integration/test_migrations_apply.py— 8 passed: single head preserved, second upgrade still a no-opruff check .,mypy src,bandit -r src/ -ll— clean🤖 Generated with Claude Code