Close cross-tenant data leaks: RLS, tabular IDOR, folder scoping

🟢 open · #1 · WilliamACLove/mike-harvey-competition-legal-research- ← WilliamACLove/mike-harvey-competition-legal-research- · opened 25d ago by WilliamACLove · self · +186-6 across 5 files · ↗ on GitHub

From the PR description

Summary

Three fixes for paths that allowed one user to read another user's private data.

1. Enable Row Level Security on every data table (CRITICAL)

Only user_profiles had RLS. Every other table - projects, documents, document_versions, document_edits, chats, chat_messages, tabular_reviews, tabular_cells, tabular_review_chats, tabular_review_chat_messages, workflows, workflow_shares, hidden_workflows, project_subfolders - was reachable via the public anon key (NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY, shipped in the JS bundle). Anyone in the world could query PostgREST directly:

GET /rest/v1/chat_messages?select=*
GET /rest/v1/tabular_cells?select=*

...and read every chat message and every extracted document excerpt across all tenants.

The backend uses the service-role key (which bypasses RLS) and the frontend only queries user_profiles directly, so enabling RLS without policies on the data tables is sufficient - anon/authenticated are locked out, the backend keeps working.

  • backend/migrations/000_one_shot_schema.sql: inline enable row level security after each table.
  • backend/migrations/001_enable_rls_on_data_tables.sql: incremental migration for existing deployments.

2. Validate document access in tabular routes (CRITICAL)

POST /tabular-review, PATCH /tabular-review/:reviewId (when document_ids change), POST /tabular-review/:reviewId/regenerate-cell, and POST /tabular-review/:reviewId/generate accepted arbitrary document_ids. The user could attach any other tenant's document to a review they own and stream the LLM-extracted content back. Added filterAccessibleDocumentIds to backend/src/lib/access.ts and ensureDocAccess calls at each boundary.

3. Scope folder operations to the current project

PATCH /projects/:projectId/folders/:folderId walked parent_folder_id without eq("project_id", projectId), and PATCH /projects/:projectId/documents/:documentId/folder accepted any folder_id. Both now reject folder IDs that don't belong to the current project.

Test plan

  • Apply 001_enable_rls_on_data_tables.sql against a staging DB; confirm anon select on documents/chat_messages/tabular_cells returns [] while the backend continues to read+write through the service role.
  • As user A, create a tabular review and POST with document_ids containing a document owned by user B → expect 403.
  • As user A, PATCH an existing review adding user B's document_id → expect 403.
  • POST /tabular-review/:id/regenerate-cell with a foreign document_id → expect 404.
  • As a project member of project A, attempt to PATCH a folder's parent_folder_id to a folder in project B → expect 400.
  • As a project member, PATCH a doc's folder_id to a folder in another project → expect 400.
  • npm run build --prefix backend - passes.

Not in this PR (recommended follow-ups)

  • Replace auth.admin.listUsers({ perPage: 1000 }) in /projects/:id/people and /tabular-review/:id/people with targeted getUserById lookups.
  • Add an exp field to download tokens (backend/src/lib/downloadTokens.ts) and remove the "dev-secret" fallback in production.
  • Normalize legacy uppercase entries in projects.shared_with / tabular_reviews.shared_with so case-sensitive .includes() / @> lookups stop silently denying access.

Generated by Claude Code

Our analysis

Lock down cross-tenant data access in the Mike backend — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-1.md from inside the repo you want the changes in.

⬇ Download capture-pull-1.md