Skip to content

Storage Policies

The storage policy engine gives you CREATE POLICY-style rules over storage objects — the Supabase storage.objects model — without requiring a storage.objects table. You write per-bucket, per-operation rules whose boolean expressions reference auth.uid(), auth.role(), auth.jwt(), bucket_id, and name, and AnvilBase evaluates them on every matching object request.

This sits on top of the owner-prefix v1 model and is fully additive and fail-safe:

A bucket with no storage policies behaves exactly as before — owner-prefix v1 for user- buckets, unrestricted otherwise. Existing buckets and running deployments are unaffected until you author a policy.

How objects are guarded (the model)

AnvilBase storage objects live in S3/MinIO — there is no storage.objects table, so native Postgres RLS cannot run against them. Instead, each policy stores an admin-authored SQL boolean expression. On a request, AnvilBase evaluates the matching expressions inside your project database against a synthetic one-row table that mirrors the two columns a Supabase storage policy references:

ColumnMeaning
bucket_idthe logical bucket id (e.g. docs)
namethe object path within the bucket (e.g. alice/report.pdf)

The caller’s role and JWT claims are applied first (the same SET LOCAL role + request.jwt.claims context the REST engine uses), so auth.uid() / auth.role() / auth.jwt() resolve identically to a table RLS policy. The expression is evaluated as SELECT bool_or(<your expression>) over the synthetic row.

Operations

Every request maps to one operation:

RequestOperation
download (GET), list, signed-URL mint, image renderselect
upload (POST/PUT, single-shot or resumable), copy/move destinationinsert
delete (DELETE)delete

update is not enforced by the storage engine — there is no storage.objects table to attach an UPDATE policy to, and overwrites are gated by insert. Creating a policy with operation:"update" now returns 400 (it would otherwise be a silent dead row). Gate overwrites with an insert policy instead.

A policy carries a definition (the USING-style read/match guard) and an optional check_expression (the WITH CHECK-style write guard). For writes (insert/delete) the check_expression is used when present, else the definition. For reads (select) the definition is always used.

Permissive-OR

Multiple policies for the same (bucket, operation, role) are combined with OR — access is granted if any policy returns true (the Postgres RLS permissive default).

Fail-closed

If a policy expression is malformed (invalid SQL, a missing column or function, a type error), the request is denied with 403 and the error is logged — never a 500, never allow-on-error. A bucket whose policies exist but none of which grant access also returns 403.

service_role bypass

A service_role key bypasses storage policies (and owner-prefix) entirely. Use it only in trusted server code.

Managing policies (admin API)

Policies are managed through the admin/management API (admin scope). They are per project, per bucket.

Terminal window
# Create a policy: authenticated users may read only objects in their own folder
curl -X POST \
"$ANVILBASE_URL/api/v1/projects/$PROJECT_ID/storage/buckets/docs/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "owner can read",
"operation": "select",
"definition": "auth.uid()::text = split_part(name, '\''/'\'', 1)",
"roles": ["authenticated"]
}'
# List a bucket'\''s policies
curl "$ANVILBASE_URL/api/v1/projects/$PROJECT_ID/storage/buckets/docs/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN"
# Delete a policy by name
curl -X DELETE \
"$ANVILBASE_URL/api/v1/projects/$PROJECT_ID/storage/buckets/docs/policies/owner%20can%20read" \
-H "Authorization: Bearer $ADMIN_TOKEN"

Request fields

FieldRequiredNotes
nameyesUnique per bucket. Letters, digits, space, -, _, . (no / or quotes).
operationyesOne of select, insert, delete. (update is rejected with 400 — overwrites are gated by insert.)
definitionyesThe USING-style boolean expression.
check_expressionnoThe WITH CHECK-style write expression; falls back to definition when omitted.
rolesnoRoles the policy applies to; defaults to ["anon","authenticated"]. service_role always bypasses.

Create/delete are audit-logged (storage.policy.create / storage.policy.delete).

Examples

The Supabase storage helper functions storage.foldername(name), storage.filename(name), and storage.extension(name) are installed in every project database, so policies migrated from Supabase (which almost always use (storage.foldername(name))[1]) evaluate as written.

Per-user folders (the same outcome as owner-prefix v1, but explicit and extensible):

-- definition for select / insert / delete
auth.uid()::text = split_part(name, '/', 1)
-- or, equivalently, the Supabase idiom:
auth.uid()::text = (storage.foldername(name))[1]

Any authenticated user may read a shared bucket:

-- select definition, roles: ["authenticated"]
auth.role() = 'authenticated'

Restrict writes to a bucket and a path prefix:

-- insert check_expression
bucket_id = 'uploads' AND name LIKE 'incoming/%'

List scoping (per-object filtering)

Listing maps to select and is filtered per object: list returns only the entries the bucket’s select policy permits, evaluating the same expression — with the same auth.* context — against each object name.

  • The canonical per-user policy auth.uid()::text = (storage.foldername(name))[1] (or split_part(name, '/', 1)) makes .list('<uid>') return exactly that user’s entries. It no longer returns 403 to the owner (the pre-fix all-or-nothing behavior evaluated the policy against an empty name and denied the owner).
  • A caller with no granting select policy (e.g. anon on a policy-gated bucket) still gets 403.
  • Buckets without policies keep their existing list behavior — owner-prefix user- buckets are still scoped to the caller’s folder.
  • service_role lists the whole bucket unfiltered.

If a select policy expression fails to evaluate (a malformed expression or a missing function), list fails closed with 403 — never a 500, never a leak.

Relationship to owner-prefix v1

  • No policies on a bucket → owner-prefix v1, byte-for-byte.
  • One or more policies for an operation → those policies decide that operation (permissive-OR); the owner-prefix fallback no longer applies to it.
  • service_role bypasses both.