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​