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:
| Column | Meaning |
|---|---|
bucket_id | the logical bucket id (e.g. docs) |
name | the 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:
| Request | Operation |
|---|---|
download (GET), list, signed-URL mint, image render | select |
upload (POST/PUT, single-shot or resumable), copy/move destination | insert |
delete (DELETE) | delete |
updateis not enforced by the storage engine — there is nostorage.objectstable to attach anUPDATEpolicy to, and overwrites are gated byinsert. Creating a policy withoperation:"update"now returns400(it would otherwise be a silent dead row). Gate overwrites with aninsertpolicy 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.
# Create a policy: authenticated users may read only objects in their own foldercurl -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 policiescurl "$ANVILBASE_URL/api/v1/projects/$PROJECT_ID/storage/buckets/docs/policies" \ -H "Authorization: Bearer $ADMIN_TOKEN"
# Delete a policy by namecurl -X DELETE \ "$ANVILBASE_URL/api/v1/projects/$PROJECT_ID/storage/buckets/docs/policies/owner%20can%20read" \ -H "Authorization: Bearer $ADMIN_TOKEN"Request fields
| Field | Required | Notes |
|---|---|---|
name | yes | Unique per bucket. Letters, digits, space, -, _, . (no / or quotes). |
operation | yes | One of select, insert, delete. (update is rejected with 400 — overwrites are gated by insert.) |
definition | yes | The USING-style boolean expression. |
check_expression | no | The WITH CHECK-style write expression; falls back to definition when omitted. |
roles | no | Roles 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 / deleteauth.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_expressionbucket_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](orsplit_part(name, '/', 1)) makes.list('<uid>')return exactly that user’s entries. It no longer returns403to the owner (the pre-fix all-or-nothing behavior evaluated the policy against an emptynameand denied the owner). - A caller with no granting
selectpolicy (e.g.anonon a policy-gated bucket) still gets403. - Buckets without policies keep their existing list behavior — owner-prefix
user-buckets are still scoped to the caller’s folder. service_rolelists 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_rolebypasses both.