Skip to content

feat(db): row-level security on the workspace-owned tables (P2-1b, database half) - #400

Merged
jwvanderstam merged 1 commit into
mainfrom
feat/p2-1b-row-level-security
Sep 27, 2026
Merged

jwvanderstam merged 1 commit into
mainfrom
feat/p2-1b-row-level-security

Conversation

@jwvanderstam

Copy link
Copy Markdown
Owner

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.py fails 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 0017 puts one policy on ten tables:

Carry workspace_id (7) documents, conversations, memories, answer_feedback, connectors, workspace_api_keys, workspace_members
Borrow the parent's via EXISTS (3) document_chunks, conversation_messages, annotations

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. A grep of connection.py and 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), never SET LOCAL app.workspace_id = %s. SET is 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.
  • 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.

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 a COMMIT.

Proven non-vacuous by disabling RLS on documents: three tests go red, each naming that table.

It creates and migrates a database of its own, and that is not incidental. Written against the shared CI database this module would have skipped — that database gets its schema from _ensure_extensions_and_tables() and the Alembic chain never reaches it, because test_migrations_apply.py uses a throwaway of its own and the integration job runs plain pytest. A skip there is indistinguishable from a pass.

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, as scope= on each mixin call. The intended hook was P0-2's request_scope() contextvar, but it is bound in exactly one place: api_routes.py:179, the chat SSE stream, read by the LLM retrieval tools. Hooking get_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 the ALL_WORKSPACES paths — 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_id is NULL are invisible to a scoped transaction. Correct today — migration 0003 backfilled every existing row — but GKB-1 specifies its global knowledge base as exactly those rows, so it will need OR workspace_id IS NULL or 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 0
  • tests/integration/test_migrations_apply.py — 8 passed: single head preserved, second upgrade still a no-op
  • ruff check ., mypy src, bandit -r src/ -ll — clean

🤖 Generated with Claude Code

…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>
@sonarqubecloud

Copy link
Copy Markdown

@jwvanderstam
jwvanderstam merged commit 5707cc1 into main Sep 27, 2026
15 checks passed
@jwvanderstam
jwvanderstam deleted the feat/p2-1b-row-level-security branch September 27, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant