Skip to content

feat(db): enforce row-level security on workspace-scoped transactions (P2-1b-iii) - #407

Draft
jwvanderstam wants to merge 5 commits into
mainfrom
feat/p2-1b-iii-role-switch
Draft

jwvanderstam wants to merge 5 commits into
mainfrom
feat/p2-1b-iii-role-switch

Conversation

@jwvanderstam

@jwvanderstam jwvanderstam commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Draft: depends on #404, #405 and #406. The branch carries their commits underneath this one. Review only the top two commits, 2df1a04 and 33f2695. After those three merge, I'll rebase onto main and mark the PR ready.

The last of three PRs for the application half of P2-1b (ADR-5). This one changes behaviour: row-level security is now enforced.

What changes

  • get_connection(scope=<workspace>) opens the transaction with SET LOCAL ROLE localchat_scoped and set_config('app.workspace_id', %s, true). The workspace is bound as a parameter, never interpolated into the SQL. ALL_WORKSPACES and None stay on the owner role. It refuses an autocommit connection, where both settings would lapse at once, and an empty workspace.
  • The role and its grants are re-applied at every boot (_apply_scoped_role, inside _ensure_extensions_and_tables(), and again after Alembic). This also covers tables a later release adds. See "Restore" below for why this isn't a migration.
  • search_memories runs one scoped transaction per authorised workspace and merges the results by similarity. Under RLS, a single ANY([...]) query silently dropped the additional workspaces.
  • Scoped transactions also set hnsw.iterative_scan = strict_order and hnsw.ef_search = 400. The next section explains why.

The benchmark found a recall regression, not a latency one

Under the policy, Postgres swaps the exact per-workspace vector scan for the HNSW index filtered afterwards. The benchmark used 10,000 clustered chunks across five workspaces, the application's own search_similar_chunks, 200 interleaved queries, and a top-40 request:

Scoped path Rows of 40 Overlap with exact Semantic median / p95
before (owner, exact plan) 40 100% 9.1 / 10.1 ms
RLS, plain 19.3 (min 0) 48% 1.9 / 2.4 ms
RLS + iterative, ef 100 40 84% 2.5 / 4.0 ms
RLS + iterative, ef 400 (chosen) 40 92% 3.8 / 4.9 ms
  • Lexical search costs about +0.6 ms under RLS.
  • A small workspace (about 1% of chunks) kept the exact plan throughout.
  • Random vectors were tried first and discarded. They are the worst case for any approximate index, so they overstated the gap.
  • test_a_scoped_vector_search_still_returns_top_k pins the fix. With the two settings removed, 20 queries returned [34, 25, 38, 0, 0, 0, …] rows. It needs 10,000 chunks: at 4,000 the planner keeps the exact plan and the test passes either way.

This requires pgvector 0.8 or later. An older version refuses the setting and scoped queries fail loudly. The shipped image is pgvector/pgvector:pg16, which runs 0.8.2. The Scaleway managed database's pgvector version has not been checked.

Tests, each confirmed to fail with its fix removed

  • test_the_application_connection_enforces_the_scope: the real Database pool runs as localchat_scoped with the workspace set, and an unfiltered SELECT count(*) FROM documents sees 1 row in its own workspace and 0 in a foreign one. So the database is doing the hiding, not the query.
  • test_a_table_added_later_is_granted_to_the_scoped_role_at_boot: a new table is unreachable on the scoped path before boot and reachable after it.
  • test_a_scoped_vector_search_still_returns_top_k covers the recall fix.
  • test_scoped_connection.py pins the exact statements in the fast suite, including that ALL_WORKSPACES and None issue nothing.
  • Memory tests check that each workspace gets its own scoped transaction, and that results merge by similarity and are cut to top_k (two workspaces, interleaved scores).
  • Existing test fixed: the RLS fixture connection is now autocommit. Before, conn.transaction() nested as a savepoint and SET LOCAL ROLE outlived it, so "the owner can see the seeded row" could run as the restricted role.

Verification (local, isolated from the live stack)

  • ruff, mypy, bandit: clean.
  • Fast suite: 3204 passed, 0 failed.
  • Integration suite on a fresh Postgres, starting like CI's with no role and no migrations: 333 passed, 0 failed. Both suites were re-run after 33f2695 with the same result.

Restore: restore-proof caught a problem, and it predates this PR

The first version of this PR used migration 0019 (ALTER DEFAULT PRIVILEGES). restore-proof failed on it, because a non-superuser cannot restore a dump that carries it. Looking closer showed that the managed-Postgres restore recipe has been broken since 0017 (#400) for any new cluster:

  • A role is a cluster object, and pg_dump never carries it.
  • So the dump's GRANT … TO localchat_scoped fails with role "localchat_scoped" does not exist.
  • Even a restore that got through would leave 0017 recorded as applied with its role missing. With RLS enforced, every scoped query would then fail.

The fix (33f2695):

  • 0019 is dropped. The role and its grants are re-applied at every boot instead.
  • OPERATIONS.md: the recipe gains --no-privileges, plus a new row in its "what fails and why" table.
  • restore-proof restores that way and asserts the grant is absent before boot and present after.

Verified by hand in a second, empty cluster:

Requirement: the app identity needs CREATEROLE, or the provider must create localchat_scoped (NOLOGIN) once. 0017 already needed that.

One deviation from what was agreed

We agreed that the integration test database would run the Alembic chain. That turned out not to be needed. Every fixture that reaches a scoped path already migrates: test_ingest_ask_cite explicitly, and the live-server fixtures through the app's bootstrap. A missing role also fails loudly ("role does not exist") instead of passing. So there's no conftest change in this PR. It's easy to add if you still want it.

Not verified

  • The Scaleway pgvector version (see above).
  • Answer-level quality with real embeddings. The benchmark measures overlap with the exact top-40, not answer quality. The reranker re-scores these candidates. P2-3's evaluation is where an answer-level difference would show.

🤖 Generated with Claude Code

jwvanderstam and others added 5 commits September 29, 2026 09:27
…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>
… (P2-1b-iii)

get_connection(scope=<workspace>) now opens the transaction with
SET LOCAL ROLE localchat_scoped and set_config('app.workspace_id', ..., true),
so Postgres refuses other workspaces' rows even when a query omits its WHERE
clause. ALL_WORKSPACES and None stay on the owner role (ADR-5).

- Migration 0019: default privileges, so tables a later release adds are not
  "permission denied" on the scoped path only.
- search_memories runs one scoped transaction per authorised workspace; under
  RLS a single ANY(...) query silently dropped the additional ones.
- Scoped transactions set hnsw.iterative_scan = strict_order and
  hnsw.ef_search = 400. Without them the policy turned exact per-workspace
  vector search into a filtered HNSW scan returning 19 of 40 rows on average,
  as few as 0. With them: 40 of 40, 92% overlap with exact, 3.8 ms median vs
  9.1 ms. Requires pgvector 0.8+.
- test_row_level_security: fixture connection is autocommit, so owner checks
  really run as the owner; new tests for the app connection, default
  privileges, and scoped top_k recall, each failing with its fix removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a new cluster

restore-proof failed on migration 0019: a dump carrying ALTER DEFAULT
PRIVILEGES cannot be restored by a non-superuser. Looking closer showed the
managed-Postgres restore recipe had been broken since 0017 for any new
cluster: a role is a cluster object pg_dump never carries, so the dump's
GRANT ... TO localchat_scoped fails, and even a restore that got through
would leave 0017 recorded with its role missing - every scoped query failing
once RLS is enforced.

- Drop 0019. _ensure_extensions_and_tables() now creates localchat_scoped if
  missing, grants it to the app identity and grants it all current tables and
  sequences, every boot; bootstrap re-applies it after Alembic so tables a
  migration adds are covered on the same boot.
- OPERATIONS.md: the managed recipe gains --no-privileges; the app restores
  the grants on start. Needs CREATEROLE, or the role created once.
- restore-proof restores that way and asserts the grant is absent before boot
  and present after.

Verified by hand in a second, empty cluster: the old recipe fails with
role "localchat_scoped" does not exist; with the new one a non-superuser
identity restored, booted, created the role and served a scoped query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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