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:
| Table | Description | Open to developers? |
|---|---|---|
storage.buckets | Bucket metadata | ✅ Read & write (gated by RLS) |
storage.objects | Object metadata (one row = one object) | ✅ Read & write (gated by RLS) |
storage.s3_multipart_uploads | S3-compatible multipart upload tasks | Read-only (anon / authenticated) |
storage.s3_multipart_uploads_parts | Multipart parts | Read-only (anon / authenticated) |
storage.migrations | Internal migration version table | Not 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()
);
public really meanspublic = 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_tokensis a generated column splittingnameby/, useful for filtering by directory level.metadatais written by the Storage API and typically containssize,mimetype,cacheControl,lastModified.user_metadatais 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:
| Table | Default GRANT for anon / authenticated / service_role | Permission gate |
|---|---|---|
storage.buckets | ALL | RLS only |
storage.objects | ALL | RLS only |
storage.s3_multipart_uploads* | anon / authenticated: SELECT; service_role: ALL | RLS + 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_rolehasBYPASSRLSand 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:
| Function | Returns | Example |
|---|---|---|
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() | text | The current Storage operation type, injected by the Storage API |
storage.allow_only_operation(op) | boolean | True when the current operation matches a single op |
storage.allow_any_operation(ops) | boolean | True 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()
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
| DML | Client action | Matching policy |
|---|---|---|
SELECT | getTempFileURL / download / list | FOR SELECT |
INSERT | uploadFile (creating a new object) | FOR INSERT WITH CHECK |
UPDATE | overwrite / change metadata | FOR UPDATE USING + WITH CHECK |
DELETE | deleteFile | FOR DELETE USING |
You cannot run
DELETE FROM storage.objectsdirectly: theprotect_deletetrigger raises a42501error. Deletion must go through the SDK / Storage API, which coordinates "delete the object first, then the row" inside a transaction. See FAQ.
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 CHECKblocks 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.
| Goal | Recommendation |
|---|---|
| Allow file download | Allow only object-read operations |
| Allow signed URL creation | Allow only objects the user can share |
| Forbid directory listing | Do not allow list operations, or restrict path_tokens[1] = auth.uid() |
| Allow metadata reads | Return 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
- RLS policy patterns — 8 copy-pasteable templates
- Postgres-Native: Authentication — roles, JWT,
auth.uid() - PostgreSQL database — Permission management — the corresponding doc on the business-table side