Skip to main content

Permission management

The permission model for Postgres-Native cloud storage is identical to that of business tables: three database roles (anon / authenticated / service_role) act as the principals, and RLS policies are the only gate, applied to the metadata tables in the storage schema.

storage schema overview

The storage schema contains the following tables:

TableDescriptionOpen to developers?
storage.bucketsBucket metadata✅ Read & write (gated by RLS)
storage.objectsObject metadata (one row = one object)✅ Read & write (gated by RLS)
storage.s3_multipart_uploadsS3-compatible multipart upload tasksRead-only (anon / authenticated)
storage.s3_multipart_uploads_partsMultipart partsRead-only (anon / authenticated)
storage.migrationsInternal migration version tableNot exposed (REVOKEd)

storage.buckets

CREATE TABLE storage.buckets (
id text PRIMARY KEY,
name text NOT NULL, -- ≤ 100 chars (enforced by trigger)
public boolean DEFAULT false,
avif_autodetection boolean DEFAULT false,
file_size_limit bigint, -- per-file size limit (bytes)
allowed_mime_types text[], -- MIME-type whitelist
owner_id text, -- creator sub
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
What public really means

public = true does not automatically bypass RLS in PostgreSQL. It is just a metadata flag; whether objects are actually publicly readable depends on the SELECT policy you write on storage.objects. A typical pattern:

CREATE POLICY objects_public_read ON storage.objects
FOR SELECT TO anon, authenticated
USING (
EXISTS (
SELECT 1 FROM storage.buckets b
WHERE b.id = storage.objects.bucket_id AND b.public
)
);

storage.objects

CREATE TABLE storage.objects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id text REFERENCES storage.buckets(id),
name text, -- object key, may contain '/'
version text,
owner_id text, -- uploader sub
metadata jsonb, -- system-side metadata (size / mimetype, ...)
user_metadata jsonb, -- user-defined metadata
path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/')) STORED,
last_accessed_at timestamptz DEFAULT now(),
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);

Highlights:

  • Unique constraint on (bucket_id, name): object keys are unique within a bucket.
  • path_tokens is a generated column splitting name by /, useful for filtering by directory level.
  • metadata is written by the Storage API and typically contains size, mimetype, cacheControl, lastModified.
  • user_metadata is for application-defined fields; you can build a GIN index for faster jsonb queries.

Roles and GRANT: a key difference from business tables

Business tables use a two-layer model: GRANT (table-level) + RLS (row-level). On the storage tables it is different:

TableDefault GRANT for anon / authenticated / service_rolePermission gate
storage.bucketsALLRLS only
storage.objectsALLRLS only
storage.s3_multipart_uploads*anon / authenticated: SELECT; service_role: ALLRLS + GRANT

Conclusion: when writing policies on storage.buckets / storage.objects, you do not need to GRANT ... TO .... Table-level access has been opened up by the initialization scripts, and all control is funneled into RLS. This is a notable contrast with public.* business tables, where you must GRANT first and then write RLS.

service_role has BYPASSRLS and skips all policies — only use it on the server side via an API Key.

RLS policies

RLS is already ENABLEd on storage.buckets and storage.objects. Rows not matched by any ALLOW policy are denied by default.

Reading login state inside a policy

auth.uid() -- current user sub
auth.role() -- current role (anon / authenticated / service_role)
auth.jwt() -- full claims (jsonb)

See Postgres-Native: Authentication — reading login state in SQL.

Helper functions for object paths

The storage schema ships a set of helpers that make policies natural to write:

FunctionReturnsExample
storage.filename(name)text'a/b/c.png''c.png'
storage.foldername(name)text[]'a/b/c.png'{'a','b'}
storage.extension(name)text'a/b/c.png''png'
storage.operation()textThe current Storage operation type, injected by the Storage API
storage.allow_only_operation(op)booleanTrue when the current operation matches a single op
storage.allow_any_operation(ops)booleanTrue when the current operation matches any op in the list

The path_tokens generated column is equivalent to storage.foldername(), but because it is a generated column it can be indexed:

-- Equivalent expressions
(storage.foldername(name))[1] = auth.uid()
path_tokens[1] = auth.uid()
When to use storage.operation()

RLS by default can only branch on DML (SELECT / INSERT / UPDATE / DELETE). When you need finer granularity — for example "allow overwrite uploads but forbid metadata updates" — you can read the operation type injected by the Storage API:

CREATE POLICY allow_overwrite_only ON storage.objects
FOR UPDATE TO authenticated
USING (owner_id = auth.uid())
WITH CHECK (
owner_id = auth.uid()
AND storage.allow_any_operation(ARRAY['storage.overwrite', 'storage.move'])
);

DML to client-action mapping

DMLClient actionMatching policy
SELECTgetTempFileURL / download / listFOR SELECT
INSERTuploadFile (creating a new object)FOR INSERT WITH CHECK
UPDATEoverwrite / change metadataFOR UPDATE USING + WITH CHECK
DELETEdeleteFileFOR DELETE USING

You cannot run DELETE FROM storage.objects directly: the protect_delete trigger raises a 42501 error. Deletion must go through the SDK / Storage API, which coordinates "delete the object first, then the row" inside a transaction. See FAQ.

Why no direct client-to-COS upload?

In classic mode, clients typically get a temporary signature and upload directly to COS. In Postgres-Native, all reads and writes go through the Storage API, which transactionally coordinates metadata writes, owner_id injection, RLS checks, and object-storage operations. This guarantees:

  • Strong consistency: no "object exists in COS but no row in storage.objects" (or vice versa) orphan state
  • Trustworthy metadata: owner_id, metadata.size, mimetype, ... are written by the Storage API and cannot be forged by the client
  • End-to-end RLS: the INSERT WITH CHECK blocks unauthorized writes at the database layer — they never reach COS

SDK methods (uploadFile / deleteFile / getTempFileURL) remain identical to classic mode; only the underlying transport changes. No application refactoring is required.

Fine-grained SELECT operations

Downloading, creating signed URLs, reading metadata, and listing folders may all map to SELECT. If your business needs to allow access to a known object but forbid directory listing, use storage.operation() helpers to distinguish Storage operations.

GoalRecommendation
Allow file downloadAllow only object-read operations
Allow signed URL creationAllow only objects the user can share
Forbid directory listingDo not allow list operations, or restrict path_tokens[1] = auth.uid()
Allow metadata readsReturn only objects whose existence the user may know

Use backend-injected operation names as the source of truth. Start by narrowing bucket, path, and owner conditions, then add storage.operation() for finer control.

Joining business tables

storage and public (the business schema) live in the same PostgreSQL instance and can be JOINed directly — this is the biggest capability gain over classic-mode storage.

Example: gate cover image visibility by article visibility

-- Business table: articles
CREATE TABLE public.articles (
id bigserial PRIMARY KEY,
owner_id text DEFAULT auth.uid(),
cover_key text, -- e.g. 'articles/<article_id>/cover.png'
is_public boolean DEFAULT false
);

-- Read policy on the cover object: follows articles.is_public
CREATE POLICY cover_read ON storage.objects
FOR SELECT TO anon, authenticated
USING (
bucket_id = 'article-assets'
AND EXISTS (
SELECT 1 FROM public.articles a
WHERE a.cover_key = storage.objects.name
AND (a.is_public OR a.owner_id = auth.uid())
)
);

Next steps