Postgres row-level security
Postgres RLS as a compile target and import source for PermDock policies, covering CREATE POLICY semantics, Supabase and Neon helpers, GUC patterns, pg_policies introspection, the Drizzle and Prisma 8 authoring surfaces, testing and the risks of round-tripping.
What it is
PostgreSQL row-level security lets a table carry policies that filter or reject rows per statement, evaluated by the database regardless of which application issued the query. The relevant syntax is CREATE POLICY:
CREATE POLICY name ON table
[ AS PERMISSIVE | RESTRICTIVE ]
[ FOR ALL | SELECT | INSERT | UPDATE | DELETE ]
[ TO role, ... ]
[ USING (expr) ]
[ WITH CHECK (expr) ]Semantics that matter for code generation:
- Combination. Permissive policies for the same command are OR'd; restrictive policies are AND'd; the result is
(AND restrictives) AND (OR permissives). Zero permissive policies means deny.FOR ALLpolicies fold into whichever command is being evaluated. Defaults arePERMISSIVE,ALLandPUBLIC. - Clause legality.
SELECTtakes onlyUSING;INSERTtakes onlyWITH CHECK;DELETEtakes onlyUSING;UPDATEandALLtake both, and ifWITH CHECKis omittedUSINGis reused for the new row. - Cross-command coupling.
UPDATEandDELETEstatements that read columns (WHERE,RETURNING,SET) also need a passingSELECTpolicy, andRETURNINGrows must satisfy it or the statement errors. - Denial modes.
USINGsilently filters (zero rows);WITH CHECKraises42501; a missingGRANTraises42501before any policy runs. - Expressions cannot contain aggregates or window functions and run with the caller's privileges, so referenced tables and functions need grants;
LEAKPROOFfunctions may run before policy quals.
Introspection is through the pg_policies view (schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check), backed by pg_policy (polroles of 0 means PUBLIC). The qual and with_check columns are deparsed with pg_get_expr, which returns normalised SQL (explicit casts, parenthesisation, ( SELECT auth.uid() AS uid)), not the original text. Enablement lives in pg_class.relrowsecurity and relforcerowsecurity.
Supabase
Every request runs as anon or authenticated: PostgREST issues SET LOCAL ROLE from the JWT role claim, and service_role has bypassrls. Grants are separate from policies, and new tables in public may already grant all four privileges to anon and authenticated, so generated SQL revokes and re-grants next to the policies.
auth.uid()is NULL when unauthenticated, sonull = user_idis never true.auth.jwt()iscurrent_setting('request.jwt.claims', true)::jsonb; readapp_metadata, never user-writableuser_metadata, and expect the claims to be stale until the token refreshes.auth.role()andauth.email()are deprecated in favour of theTOclause.- PostgREST exposes the claims as the GUC
request.jwt.claims. The per-claimrequest.jwt.claim.<name>settings are legacy: PostgREST 12.0.0 removeddb-use-legacy-gucs, the option that wrote them (changelog). Even so, Supabase'sauth.uid()still readsrequest.jwt.claim.subfirst. PermDock'swithSubjectpreambles andrls verifyset onlyrequest.jwt.claims, andpermdock_user_id()reads only that. - The performance guide and the Splinter lints say: wrap helpers as
(select auth.uid())so they run once per statement (auth_rls_initplan); index every filtered column; always writeTO authenticated; preferIN (subselect)over correlated joins, or asecurity definerfunction (withset search_path = '', in a non-exposed schema) to avoid42P17recursion; avoid many permissive policies per role and command (multiple_permissive_policies). - The RBAC pattern (
app_roleandapp_permissionenums,user_rolesandrole_permissionstables, acustom_access_token_hookthat injectsuser_role, and asecurity definer stableauthorize(permission)function used asusing ((select authorize('channels.delete')))) is a named-permission catalog in SQL. - Policies appear nowhere else:
supabase gen types typescriptemits tables, views, functions and enums only, and the Supabase MCP server has no policy tool, so introspection meansexecute_sqlagainstpg_policies. Itsget_advisorstool runs the Splinter RLS lints.
Generic Postgres, Neon, Nile and PostgREST
Without Supabase helpers, the pattern is a GUC set per transaction (set_config('app.user_id', ..., true)) and policies reading current_setting('app.user_id', true)::uuid. Neon's Data API validates the JWT and pg_session_jwt provides auth.user_id() (text sub) and auth.uid() (uuid) with roles authenticated and anonymous. Nile isolates tenant-aware tables through set nile.tenant_id and a reserved tenant_id column instead of user-written policies, so it is a tenant-equality target only. PostgREST uses an authenticator role, SET LOCAL ROLE from the role claim, claims in request.jwt.claims and a db-pre-request hook.
Authoring surfaces
- Drizzle:
pgPolicy(name, { as, to, for, using, withCheck })in the table's third argument (any policy auto-enables RLS;pgTable.withRLSenables it with none),pgPolicy(...).link(table)for tables Drizzle does not own,pgRole(...).existing().drizzle-orm/supabaseexportsanonRole,authenticatedRole,serviceRole,authUidandcreateDrizzle(token, ...), which sets the claim GUCs andset local roleinside a transaction;drizzle-orm/neonexportscrudPolicy({ role, read, modify }).drizzle-kit generatediffs policies. - Prisma 8 (changelog, prisma-next#945):
@@rlson a model enables RLS fail-closed; top-levelpolicy_select|insert|update|delete|allblocks carrytarget,roles,usingandwithCheck;roledeclarations;migration planemitsCREATE POLICYandALTER POLICY ... RENAME;db verifyfails on policy drift;@prisma/orm-extension-supabasesupplies the Supabase roles. Prisma up to 7 has no native RLS; the sanctioned pattern is a client extension that runsset_configand the query inside$transaction, and nested many-to-many writes can lose the GUC. - Kysely has nothing built in (use
onReserveConnectionor a transaction plusset_config). Kysera's@kysera/rlsdrives app-side filters and nativeCREATE POLICYfrom one schema, but only in one direction and only for policies written as raw SQL. - Schema-as-code tools: Atlas has
policyblocks,row_security, lint rules andatlas schema test; Bytebase reviews policies as SQL; Sqitch is plain SQL.CREATE POLICYhas noIF NOT EXISTS, so idempotent scripts useDROP POLICY IF EXISTSor aDOblock overpg_policies. - Parsers:
pgsql-parser(libpg_query in WASM, symmetric parse and deparse) and@pgsql/parser(PG 15 to 18 grammars); the pure TypeScriptpgsql-ast-parserhas incomplete coverage. Sincequalis a bare expression, parseSELECT 1 WHERE <qual>and take the where clause.
Testing
pgTAP via supabase test db gives structural asserts (policies_are, policy_roles_are, policy_cmd_is, has_table_privilege) and behavioural ones after set local role authenticated and set local request.jwt.claims. Basejump's supabase-test-helpers add tests.authenticate_as(), tests.rls_enabled() and tests.freeze_time(). Match the assertion to the denial mode: a missing grant and a WITH CHECK failure are throws_ok ... '42501'; a USING filter is is_empty over a statement with RETURNING, followed by a read proving the row is intact. Never prove an allowed write with lives_ok, which passes on zero rows.
Splinter, supautils and pg_jsonschema
Three Supabase projects constrain what generated SQL may contain.
- Splinter is a set of SQL lint views over the catalog, released in CalVer (2026.09.1); each release ships the combined
splinter.json.supabase db advisors --localor--db-urlruns it,--type security --fail-on warnmakes it a gate, and the Supabase MCP server'sget_advisorsreturns the same rows. The lints that generated RLS can trip are 0003auth_rls_initplan(an unwrappedauth.uid(),auth.jwt()orcurrent_setting()in policy text), 0006multiple_permissive_policies(two permissive policies for one role and command), 0010security_definer_view, 0011function_search_path_mutable(any function, triggers included, withoutset search_path), 0013rls_disabled_in_public, 0014extension_in_public, 0024rls_policy_always_true, 0025public_bucket_allows_listing, and 0028 and 0029 (asecurity definerfunction in an exposed schema executable byanonorauthenticated). A schema is exposed when it ispublicor listed in[api] schemasofsupabase/config.toml; splinter reads that list frompgrst.db_schemas. - supautils (3.4.4) runs inside every Supabase database. For
postgres, the role that runs migrations, it rejectsalter role(attributes,rename,password) anddrop roleon a reserved role:anon,authenticated,service_role,authenticator,dashboard_user,pgbouncerand thesupabase_*roles. It also rejects a grant of a reserved membership such asauthenticatoror asupabase_*admin role.alter role … setstays allowed on the four API roles, andcreate event triggerruns. A migration that passes against plain Postgres can fail on Supabase for these reasons alone; PD063 reports them. - pg_jsonschema adds
extensions.jsonb_matches_schema(schema json, instance jsonb)and itsjsontwin; Supabase ships 0.3.3. It validates Draft 2020-12 without assertingformat(usepattern), resolves no remote$ref(inline$defs), and compiles the schema on every call; compiled-schema functions exist on the main branch only. Acheckconstraint over it is the database's boundary validation for ajsonbcolumn, addednot validand then validated so existing rows do not block the migration.
Why it matters for PermDock
RLS is the only enforcement layer that survives a bypassed application, and Supabase users already write it. A policy written twice, once in SQL and once in TypeScript, drifts. PermDock's portable condition AST is designed so one condition evaluates in the UI, filters arrays, compiles to where clauses and generates RLS, and so existing RLS imports back into a typed catalog. No other tool does the import direction: supazod-style generators read gen types output, which has no policy data. ZenStack is the contrast case: it compiles @@allow / @@deny into application-side query filters and skips RLS; PermDock offers both app-side where filters and RLS, with parity tests as the glue. See the rls adapter and CLI rls.
How PermDock uses it
permdock rls generate --target drizzle|sql|prisma --dialect supabase|neon|guc
permdock rls import --db $DATABASE_URL --out src/permissions.generated.ts
permdock rls verify --db $DATABASE_URL- Generate. Roles and grants become policies (mapping below). A
SELECTpolicy is generated or verified whenever update or delete grants exist, and a table left with zero permissive policies for a role and command is warned about. CompanionENABLE ROW LEVEL SECURITY, optionalFORCE, grant and revoke statements,(select ...)wrappers and index suggestions are emitted alongside. Role checks go through generatedsecurity definerhelpers (permdock_has, and onepermitted_<scope>_idsand one membership-onlymember_<scope>_idsper named scope the policy declares) over a seededrole_permissionstable, called uncorrelated so they run once per statement (see below), and each table gets one permissive policy per command. With--rbac supabase, theuser_roles/authorize()scaffold is generated as well;permdock supabase hook generatewrites the hook. SQL output is idempotent (drop policy if existsthencreate policy); the Drizzle and Prisma targets leave diffing to drizzle-kit, Prisma 8 or Atlas. - Import.
pg_policiespluspg_class.relrowsecurityare read,qualandwith_checkparsed withpgsql-parserand pattern-matched to portable nodes; anything else becomesopaque({ sql, fingerprint }), kept verbatim for regeneration and flagged in the catalog as untestable app-side. Fingerprints come from the deparsed AST, sopg_get_exprnormalisation does not register as drift.FOR ALLis split into four entries (export merges them only if identical);roles = {public}maps to all subjects and non-Supabase role names to opaque constraints. The output is a deterministicdefinePermissions()file that merges with hand-written ones by key. - Verify. For each fixture (subject, row, next row, action),
can()runs in-process and the same operation runs insideBEGIN ... ROLLBACKwithset local roleandset_config('request.jwt.claims', ...), usingRETURNING; outcomes are classifiedallowed,filtered(zero rows) orrejected(42501) and any mismatch fails. Only the outcome is compared, so a three-valued-logic or cast difference that changes the outcome is a mismatch and one that does not is invisible; an opaque grant is reported as a note, never evaluated app-side. Emitted as pgTAP or run from Node.
The portable subset
| Portable node | Supabase SQL | Neon or generic GUC |
|---|---|---|
eq(row.user_id, principal.id) | (select auth.uid()) = user_id | (select auth.user_id()) = user_id or (select current_setting('app.user_id', true))::uuid = user_id |
eq(row.tenant_id, principal.claim.tenant_id) | tenant_id = ((select auth.jwt()) ->> 'tenant_id')::uuid | tenant_id = (select current_setting('app.tenant_id', true))::uuid |
eq(principal.claim.user_role, 'admin') | ((select auth.jwt()) ->> 'user_role') = 'admin' | GUC variant |
| Claim array contains a row column | team_id in (select jsonb_array_elements_text((select auth.jwt())->'app_metadata'->'teams'))::uuid | none |
| Membership via join table | id in (select resource_id from memberships where user_id = (select auth.uid()) [and role in (...)]) | same |
| Role check of a global role | (select permdock.permdock_has('channels.delete')) | same helper, claims from GUCs |
| Role check of a role on a named scope | org_id in (select permdock.permitted_organization_ids('channels.delete')), customer_id in (select permdock.permitted_customer_ids('quote.read')) | same helpers |
| Named permission in a hand-written policy | (select authorize('channels.delete')) with --rbac supabase; imported as opaque | opaque unless a function is mapped |
Literal comparisons, isNull, and, or, not, true, false | literal SQL | literal SQL |
Import recognises both the EXISTS (select 1 ...) and the IN (subselect) membership forms, and the helper calls in both policy layouts; generate emits the helpers for role checks and keeps exists joins only for resource-scoped roles. now() and interval arithmetic, CASE, multi-join subqueries, current_user, custom functions and claim checks beyond simple equality become opaque. The operators are written with the names on the conditions page.
One helper per scope
A policy that declares organization and customer (inside it) gets permitted_organization_ids and permitted_customer_ids. Each returns the instances of its own scope, read from memberships of exactly that scope, so an organization owner's customer set is empty and a portal contact's organization set is empty (no cascade). A resource that carries both keys is narrowed per grant: an organization role compares organization_id, a customer role compares customer_id, and the two branches OR together in one permissive policy. Memberships under the first scope are narrowed to the active-tenant claim when it is set. In database mode each helper reads the table mapped for its scope; in jwt mode, the memberships claim entries whose scope is its own, with the first scope's id in within.
Why InitPlans
Postgres evaluates a policy's USING expression as a filter on every candidate row. A subquery that references no column of the outer row is uncorrelated: the planner turns a scalar one, (select f()), into an InitPlan and an x in (select f()) into a hashed SubPlan, runs either once when the statement starts, and the per-row filter only compares against the cached result. A subquery or function call that mentions a row column (authorize('post.read', "orgId"::text), exists (... where m.org_id = "orgId") that the planner cannot hash) is correlated and runs for every row. Splinter's auth_rls_initplan lint is the same observation applied to auth.uid(), and the guc dialect wraps current_setting(...) the same way.
The generated helpers are built so every call is uncorrelated: they take only a grant key, read the subject from auth.uid() and the claims inside the function, and return either a boolean (global roles) or the set of one scope's ids (scoped roles). The row-dependent part of the policy is then a plain comparison of the row's scope column against that set. On 10,000 rows across 20 tenants the per-row shape runs its role check 10,000 times; the helper shape runs it once per helper and is over 1,000 times faster in tests/integration/bench (the test asserts loops = 1 and a 5x floor).
security definer is what lets the helper read role_permissions, user_roles and the membership table without granting authenticated access to them, and it prevents the recursion error (42P17) a policy over the membership table would otherwise hit. set search_path = '' with fully qualified names closes the search_path hijack a definer function is otherwise open to, and execute is granted to authenticated only.
Mapping table
| PermDock concept | Postgres / Supabase concept |
|---|---|
allow(permission, { where }) | AS PERMISSIVE ... USING (cond) |
deny(permission, { where }) | AS RESTRICTIVE ... USING ((cond) IS NOT TRUE), one restrictive policy per table and command; a cond that is NULL on a row leaves the row visible, as the evaluator does |
| Role held globally / at a named scope | (select permdock_has('<key>')) / <scope key> in (select permitted_<scope>_ids('<key>')) |
| Any membership of a named scope | <scope key> in (select member_<scope>_ids()) |
read action | FOR SELECT USING |
create action, check | FOR INSERT WITH CHECK (next row) |
update action, where + check | FOR UPDATE USING (current) WITH CHECK (next); a grant on the current row only collapses to USING |
delete action | FOR DELETE USING |
| App roles | JWT claims, never Postgres roles; policies default TO authenticated, public-visible permissions also get one policy TO anon (anonymous on Neon), so no role has two permissive policies for one command |
service_role | Never emitted (bypasses RLS) |
| Anything not portable | opaque({ sql, fingerprint }) |
pg_policies row | Catalog entry: table, cmd, permissive, roles, condition or opaque, fingerprint, source SQL |
--target drizzle / --target prisma output | Drizzle pgPolicy; Prisma 8 policy_* block with @@rls |
Risks
- Semantic drift. SQL three-valued logic (
auth.uid()NULL, nullable columns) versus JavaScript booleans;uuidversustextsub; stale JWT claims versus live application lookups. USINGversusWITH CHECKconfusion, the classic "user reassignsuser_id" hole;ALLpolicies hiding intent; theSELECTprerequisite forUPDATEandDELETE.- Grants. A
42501from a missing grant looks like a policy denial, so code generation owns grants too. - Bypasses.
service_role,bypassrlsand table owners withoutFORCEskip policies; views default tosecurity definer(usesecurity_invoker = true). - Performance. Unwrapped helpers, unindexed filter columns, correlated joins, many permissive policies, recursion; run Splinter or
get_advisorsnext toverify, which does not read them. - Round-trip stability.
pg_get_exprrewrites text, so import-then-generate compares ASTs, never policy text.
Sources
- PostgreSQL CREATE POLICY, pg_policies, pg_policy.
- Supabase RLS guide, RLS performance, Custom claims and RBAC, Testing, Splinter, supautils, pg_jsonschema, supabase-mcp.
- Drizzle RLS, Neon RLS with Drizzle, Neon Data API, Nile tenant isolation, PostgREST auth.
- Prisma 8 changelog, prisma-next#945, Prisma RLS client extension.
- Kysera multi-tenancy, ZenStack access control, Atlas RLS guide, supabase-test-helpers.
- pgsql-parser, @pgsql/parser.
Last updated on
RateLimit header fields
How PermDock's HTTP adapters answer an exhausted limit grant with 429, Retry-After and the IETF RateLimit and RateLimit-Policy header fields, pinned to draft-ietf-httpapi-ratelimit-headers-11.
OpenID Connect
Where PermDock sits in an OpenID Connect deployment: the relying party or resource server runs Discovery and verifies the token, permdock/jwt maps the verified claims to a Subject, and every OIDC claim that reaches the principal has one documented home.