Access model
Theworkspaces bucket stores objects as <wsId>/<root>/.... Supabase Storage
enforces storage.objects RLS for browser and user-token clients. Owning server
APIs use the service role, which bypasses RLS. Migration
20261026170000_least_privilege_workspace_storage_rls.sql replaced the original
policies. Those let any workspace member read, write and delete every object.
All rules require a MEMBER workspace membership; guests are excluded.
workspace_storage.object_access(name, operation, actor) evaluates them. The
workspace_storage schema is not exposed through PostgREST, so clients cannot
call the helper with a forged actor:
Workspace creators count as
admin, which mirrors has_workspace_permission.
- Private roots match case-insensitively and never fall back to Drive permissions.
AI Agent Importscontains spaces; the match uses the literal segment.drive/mira/qr/**(QR codes saved to Drive) stays a Drive object; onlymira/images/**is private. - Names containing empty,
.or..segments are denied. - Clients cannot write private roots. Chat uploads use
POST /api/ai/chat/upload-url, which checks membership and chat ownership before it issues a signed upload URL. An existing chat must belong to the caller. An id that does not exist yet is allowed for draft attachments. Uploads never overwrite (upsert: false), and Supabase signed upload tokens have a fixed 2-hour lifetime. POST /api/ai/chat/delete-fileaccepts only the caller’s own temp folder or an existing chat (UUID segment) the caller created. Orphaned and non-chat folders are not deletable through it.- Meeting recording routes check membership and the recording session, then use the service role for storage. Playback signs only paths inside the verified session folder.
project-updateskeeps member access because its task route still uses a request-scoped client. Move it to a service-role route before tightening it.- The private-root list follows the Drive private upload census in Drive. Add new private roots to the helper, the Drive path policy and the test matrix together.
workspace_storage_guard_* policies enforce the same helper on every
command. The legacy Enable insert for authenticated users only policy
(WITH CHECK (true)) can no longer widen access to this bucket. Before this
migration, it let non-members insert into any workspace folder.
Performance
workspace_storage.actor_access() resolves the caller’s member, view and manage
workspace sets once per statement. Policies pass it as
(select workspace_storage.actor_access()), which runs as an InitPlan. Each row
then costs path parsing and array lookups, plus one primary-key probe on
ai_chats for chat attachment rows. Private roots are denied before any table
lookup. Guards use CASE so the bucket check runs first and other buckets never
call the helper.
Measured on a disposable local stack listing one Drive folder of 20,000 objects
as a view_drive member (median of 5 runs):
Regression coverage
apps/database/supabase/tests/workspace-storage-least-privilege-rls.sql covers
15 objects across every root. Each is probed by a non-member, a member, a
view_drive viewer, a manage_drive manager, the chat owner/uploader and
service_role, for select/insert/update/delete. With 10 structural checks
(including the workspace creator and the MEMBER default admin paths), that
is 370 assertions. Each probe runs in a rolled-back subtransaction.
Route tests in apps/web/src/__tests__/mind-chat-file-app-session-route.test.ts
cover the upload-url and delete-file ownership checks, including missing-chat
and non-UUID delete rejection.
Verify on a disposable stack
Run this on a remote devbox only, never on the shared laptop. Copyapps/database/supabase to a scratch directory and give config.toml a unique
project_id and free ports. Then, from apps/database:
stop --project-id <id> --no-backup.
Rollback
There are two rollbacks inapps/database/rollbacks/. Each is one transaction,
does not touch objects, and can run from the forward state or from the other
rollback’s state. Re-applying the forward migration body also removes either
rollback’s policies.
- Incident (preferred):
20261026170000_least_privilege_workspace_storage_rls.incident-rollback.sql. Members regain 2023-style access to non-private objects, with noview_drivecheck.- Private roots stay service-role only.
- RESTRICTIVE insert and update guards keep the legacy
WITH CHECK (true)policy from letting non-members write. - It is plain SQL and drops the helpers.
- Proof:
apps/database/verification/workspace-storage-rls-incident-rollback.sql(14 assertions).
- Faithful:
20261026170000_least_privilege_workspace_storage_rls.rollback.sql. It restores the exact 20231004102905 policies.- This reopens the HIGH finding and non-member inserts.
- Use it only when exact prior behaviour is required.
- Proof:
apps/database/verification/workspace-storage-rls-rollback-roundtrip.sql(7 assertions).
- Apply the rollback with
psql -v ON_ERROR_STOP=1. - Run its verification file.
- Re-apply the forward migration file.
- Re-run the 370-assertion test.
begin/commit with
set local lock_timeout = '5s' and statement_timeout = '30s'. Its policy DDL
needs a brief ACCESS EXCLUSIVE lock on storage.objects. On the disposable
stack, with another session holding ACCESS SHARE on the table, the migration
failed after 5 seconds with canceling statement due to lock timeout and left
no partial changes (no schema, the policies unchanged). Retry it when that
happens.
Related
- Drive storage policy and product decision: Drive
- Signed URL egress controls: Storage egress protection