Why server routes need the same rules
Theworkspaces 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)returnsallowed,forbiddenorinvalid.resolveWorkspaceStorageFolderAccessauthorizes 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_permissionforview_driveandmanage_drive;ai_chats.creator_id.
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).
AI chat attachment routes
POST /api/ai/chat/signed-read-urlaccepts only{wsId}/chats/ai/resources/...paths. Every path must pass the helper forselect, 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-urlsauthorizes the chat folder before listing it.- Public chat pages (
/ai/chats/<chatId>) do not use either route. They render shared messages fromai_chat_messageswithout 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_storageto Exposed schemas in the Supabase dashboard (Settings, Data API). - Never add it to
[api].schemasinapps/database/supabase/config.toml. - Do not move the helpers into an exposed schema (
public,private,storage,graphql_public) either.privateis exposed in this project.
config.toml change, check that the
exposed-schema list still omits workspace_storage.