The answer. Files live in object storage, but every object has a metadata row in storage.objects, and that table is where access control happens: policies with bucket_id, the full path in name, and owner_id (the uploader's user id). The design move is to put the authorization data into the path: <tenant>/<ticket>/<random>/<filename>, so a policy can split it with storage.foldername(name) (a 1-based array of segments) and check each segment against your own tables, which runs their policies as the same user and gives tenant isolation for free. Choose bucket visibility by revocability, not convenience: a public bucket serves fast, CDN-cached URLs that anyone who ever saw them keeps forever; a private bucket needs a signed URL or an authenticated download per view, which is what "removed from the tenant" should mean.
The pattern.
create policy "tenant members upload attachments" on storage.objects for insert to authenticated with check (
bucket_id = 'attachments'
and rls_helpers.has_tenant_access((storage.foldername(objects.name))[1])
and exists (select 1 from public.tickets t where t.id = (storage.foldername(objects.name))[2]::bigint) -- runs tickets' RLS
);
create policy "tenant members read attachments" on storage.objects for select to authenticated
using (bucket_id = 'attachments' and rls_helpers.has_tenant_access((storage.foldername(objects.name))[1]));
const path = `${tenant}/${ticketId}/${crypto.randomUUID()}/${file.name}`;
const { data, error } = await supabase.storage.from("attachments").upload(path, file, { upsert: false });
Watch out.
- Write
objects.name, notname, once the policy joins a table that also has anamecolumn; the ambiguity picks a column for you. - A
storage.objectspolicy cannot select fromstorage.objects(Postgres refuses the recursion); a quota like "one file per ticket" goes through asecurity definerhelper. list()returning[]means no SELECT policy, not an empty folder, and it is true for public buckets too. Upserts need SELECT and UPDATE, not only INSERT.
Related: storage-buckets-do-not-migrate-with-the-database · signed-urls-and-image-transforms-for-private-files · a-policy-that-reads-another-table-runs-that-tables-policies