Skip to main content

Why server routes need the same rules

The workspaces Storage bucket is protected by path-scoped RLS. The policies come from migration 20261026100000_least_privilege_workspace_storage_rls.sql and use the database helper workspace_storage.object_access. Routes that use the service role bypass RLS. Each of these routes is a second door to the same objects: one that signs, lists or deletes on a user’s behalf. A check weaker than RLS (for example, membership only) quietly widens access.

Shared policy helper

@tuturuuu/storage-core/workspace-storage-access mirrors the RLS rules on the server:
  • classifyWorkspaceStoragePath(name) maps an object name to its rule. Names without a UUID workspace folder are rejected, and so are names with empty, . or .. segments.
  • Percent-encoded aliases must classify the same as their literal form. Otherwise they are rejected, so transport encoding cannot select a weaker rule.
  • resolveWorkspaceStorageAccess(name, operation, context) returns allowed, forbidden or invalid. resolveWorkspaceStorageFolderAccess authorizes a listing as reading a direct child.
  • The module is framework-independent: callers inject membership, Drive permissions and chat ownership.
  • In Web, createWorkspaceStorageAccessContext (apps/web/src/lib/storage/) binds those lookups:
    • request-scoped membership;
    • has_workspace_permission for view_drive and manage_drive;
    • ai_chats.creator_id.
    Lookups are memoized per request. A lookup failure answers 500 rather than guessing.
Every rule also requires a MEMBER membership. A change to a root must update four places together:
  • the SQL helper;
  • this module;
  • the pgTAP matrix (supabase/tests/workspace-storage-least-privilege-rls.sql);
  • the parity test (packages/storage-core/src/lib/workspace-storage-access.test.ts).
The parity test reuses the pgTAP objects and expected flags.

AI chat attachment routes

  • POST /api/ai/chat/signed-read-url accepts only {wsId}/chats/ai/resources/... paths. Every path must pass the helper for select, and one denied path rejects the batch.
  • Drive files are rejected here and go through the Drive APIs, which add egress controls.
  • POST /api/ai/chat/file-urls authorizes the chat folder before listing it.
  • Public chat pages (/ai/chats/<chatId>) do not use either route. They render shared messages from ai_chat_messages without attachment URLs, so public viewing does not depend on member-wide reads.

Keep workspace_storage off the Data API

The workspace_storage schema holds the RLS helpers. workspace_storage.object_access(name, operation, actor) trusts its actor argument. Policies supply that argument from (select workspace_storage.actor_access()), which resolves the caller once per statement. authenticated holds USAGE and EXECUTE because policy expressions run as the caller. If the schema were exposed through PostgREST, any signed-in user could call object_access over /rest/v1/rpc with a forged actor (another user’s id and workspace sets). They could then probe which objects that user can reach. So:
  • Never add workspace_storage to Exposed schemas in the Supabase dashboard (Settings, Data API).
  • Never add it to [api].schemas in apps/database/supabase/config.toml.
  • Do not move the helpers into an exposed schema (public, private, storage, graphql_public) either. private is exposed in this project.
When reviewing an API settings or config.toml change, check that the exposed-schema list still omits workspace_storage.