Postgres and Supabase RLS research
What Postgres row-level security, Supabase helpers, Drizzle pgPolicy, Prisma 8 policies and the surrounding tooling make possible for a round-trip between PermDock conditions and database policies.
Source: research conducted in September 2026 across the Postgres manual, Supabase docs and source, Drizzle ORM docs, Prisma changelog and prisma-next releases, Kysera, Neon, Nile, PostgREST, ZenStack, Atlas, Bytebase, Basejump, the libpg_query parser family and pgTAP. The goal was to decide whether PermDock's portable conditions can compile to RLS, whether existing RLS can be imported, and how to prove the two agree. The result is the rls adapter, the permdock rls CLI and the Postgres RLS standard page.
Postgres RLS fundamentals relevant to codegen
Syntax and combination rules (CREATE POLICY):
CREATE POLICY name ON table [AS PERMISSIVE|RESTRICTIVE] [FOR ALL|SELECT|INSERT|UPDATE|DELETE] [TO role, ...] [USING (expr)] [WITH CHECK (expr)]. Defaults: PERMISSIVE, ALL, PUBLIC.- Permissive policies of the same command type are ORed; restrictive policies are ANDed; the result is the AND of the restrictives ANDed with the OR of the permissives. Zero permissive policies means deny.
ALLpolicies fold into whichever command type is being evaluated. - Clause legality: SELECT has no
WITH CHECK; INSERT has noUSING; DELETE has noWITH CHECK; UPDATE and ALL take both, and ifWITH CHECKis omitted theUSINGexpression is reused for the new-row check. - Cross-command coupling: UPDATE or DELETE statements that read columns (WHERE, RETURNING, SET) also need a passing SELECT or ALL policy; RETURNING rows must satisfy SELECT policies or the statement errors. Supabase restates this as "UPDATE requires a SELECT policy" (RLS guide).
- Denial semantics differ:
USINGsilently filters;WITH CHECKraises42501; a missing GRANT raises42501before any policy runs. This drives the three parity outcomes below. - Expressions cannot contain aggregates or window functions; they run with the caller's privileges, so referenced tables and functions need grants;
LEAKPROOFfunctions may run before policy quals.
Introspection:
pg_policies:schemaname,tablename,policyname,permissive(PERMISSIVEorRESTRICTIVE),roles name[],cmd(ALL,SELECT, ...),qual text,with_check text.- The underlying
pg_policycatalog:polcmd(r,a,w,d,*),polpermissive,polroles oid[](0 means PUBLIC),polqualandpolwithcheckaspg_node_tree. The view deparses them withpg_get_expr(polqual, polrelid), which returns normalised SQL (explicit casts, parenthesisation,( SELECT auth.uid() AS uid)), not the original text. Enablement lives inpg_class.relrowsecurityandrelforcerowsecurity.
Performance guidance (RLS guide, RLS performance, Splinter lints):
- Wrap stable helpers:
(select auth.uid()) = user_idforces an InitPlan so the function runs once per statement rather than per row (Splinterauth_rls_initplan, WARN). - Index every column a policy filters on; only the leading btree column counts.
- Always specify
TO authenticatedsoanonshort-circuits. - Avoid correlated joins; prefer
team_id in (select team_id from team_user where user_id = (select auth.uid())), or asecurity definerfunction (withset search_path = '', in a non-exposed schema) to bypass RLS on the join table and break42P17recursion. - Splinter
multiple_permissive_policieswarns that N permissive policies per role and command cost N evaluations and are a common source of logic bugs.
Supabase specifics
- Roles: every request runs as
anonorauthenticated(PostgREST issuesSET LOCAL ROLEfrom theroleclaim);service_rolehasbypassrls. Grants are separate from policies; new tables inpublicmay already grant all four privileges toanonandauthenticated, so codegen should emitrevoke all ... from anon, authenticated; grant ...next to the policies. - Helpers:
auth.uid()returns NULL when unauthenticated, sonull = user_idis never true;auth.jwt()returns jsonb (useapp_metadata, neveruser_metadata, and remember the JWT is stale until refresh).auth.role()andauth.email()are marked deprecated in the auth migrations ("Use auth.jwt() -> 'role'") and Supabase's own skill says to use theTOclause instead (auth migration, agent-skills). - Claims are exposed as GUCs:
request.jwt.claims(json) andrequest.jwt.claim.sub;auth.jwt()is justcurrent_setting('request.jwt.claims', true)::jsonb. This is what tests and Drizzle'screateDrizzlewrapper set. - RBAC pattern (Custom Claims and RBAC): enums
app_permissionandapp_role, tablesuser_roles(user_id, role)androle_permissions(role, permission), acustom_access_token_hook(event jsonb)PL/pgSQL Auth Hook that injectsuser_roleinto the JWT, and asecurity definer stablefunctionauthorize(requested_permission app_permission) returns booleanthat readsauth.jwt() ->> 'user_role'and countsrole_permissions; policies areusing ((select authorize('channels.delete'))). This is a named-permission catalog in SQL and is PermDock's preferred compile target for named permissions:permdock rls generate --rbac supabasescaffolds the tables, hook and function and compilespermissions.channel.deletetoauthorize('channel.delete'). supabase gen types typescriptemits onlyTables(Row,Insert,Update),Views,Functions,Enums,CompositeTypes; no policy information (generating types). Verified: policies never appear in generated types.- MCP: the official server exposes
list_tables,execute_sql,apply_migration,list_migrations,get_advisors(runs Splinter lints including the RLS ones) (supabase-mcp); there is nolist_policiestool, so policy introspection meansexecute_sqlagainstpg_policies. - Studio has an AI RLS editor (GPT-4o, prompt tuned for
(select ...)wrapping, per-command policies, discouragingalland restrictive) plus an experimental programmatic generator that walks foreign-key paths toauth.users(blog, PR #26895, PR #40881). The FK-path breadth-first search in PR 40881 is a useful reference for auto-deriving ownership policies.
Drizzle ORM
(RLS docs.)
pgTable.withRLS('t', {...})(older releases:.enableRLS()) enables RLS with no policies; adding anypgPolicyauto-enables it.pgPolicy(name, { as, to, for, using, withCheck })inside the table's third argument, withasofpermissiveorrestrictive,toa role, role array orpublic,forone ofall,select,insert,update,delete, andusing/withCheckassqlfragments;pgPolicy(...).link(existingTable)attaches to tables Drizzle does not own (for examplerealtime.messages).pgRole(name, { createRole, createDb, inherit }),.existing()to exclude from migrations;entities.roles: { provider: 'supabase' | 'neon', include, exclude }indrizzle.config.ts.drizzle-orm/supabaseexportsanonRole,authenticatedRole,serviceRole,postgresRole,supabaseAuthAdminRole,authUsers,realtimeMessages,authUid(the(select auth.uid())fragment) andrealtimeTopic, plus acreateDrizzle(token, { admin, client })wrapper that runsset_config('request.jwt.claims', ..., true),set_config('request.jwt.claim.sub', ..., true)andset local roleinside a transaction.drizzle-orm/neonexportscrudPolicy({ role, read, modify }), which expands to fourpgPolicyentries namedcrud-<role>-policy-select|insert|update|delete,authUid(col)as(select auth.user_id() = col),authenticatedRole,anonymousRole(Neon guide).drizzle-kit generatediffs policies and emitsALTER TABLE ... ENABLE ROW LEVEL SECURITYandCREATE POLICYSQL.
Drizzle is the pragmatic first compile target: PermDock emits pgPolicy(...) entries (or a sql fragment) and lets drizzle-kit own migration diffing (drizzle adapter).
Other ORMs and platforms
- Prisma up to 7: no native RLS; the sanctioned pattern is a client extension that batches
set_config('app.tenant_id', ..., true)and the query inside$transaction(prisma-client-extensions/row-level-security). Known pitfalls: the extension'squery()may not bind to the transaction client (prisma#20678) and nested many-to-many writes can lose the GUC. - Prisma 8 (July 2026): native RLS authoring.
@@rlson a model enables RLS fail-closed; top-levelpolicy_select,policy_insert,policy_update,policy_deleteandpolicy_allblocks carrytarget,roles,usingandwithCheck;roledeclarations; a TypeScript DSL withpolicySelect(),rlsEnabled()androle();migration planemitsENABLE RLS,CREATE POLICYandALTER POLICY ... RENAME;db verifyfails on policy drift;@prisma/orm-extension-supabasesuppliesanon,authenticatedandservice_role(changelog 2026-07-17, prisma-next#945, releases). A second compile target (prisma adapter). - Kysely: nothing built in; use
onReserveConnection(per-acquireSET/RESET) or a transaction plusset_config(mikro-orm discussion). Kysera's@kysera/rlsis the closest prior art to PermDock's goal: onedefineRLSSchemadrives app-side query injection (filter,allow,deny,validate) and@kysera/rls/nativePostgresRLSGeneratoremitsENABLE RLSandCREATE POLICYfor policies that carry rawusing/withCheckSQL, withsyncContextToPostgres()mirroring context intoapp.*GUCs andcreatePolicyTester()for DB-less tests (Kysera multi-tenancy). It generates in one direction only and only for raw-SQL policies. - Neon: Neon Authorize became Neon RLS and is now part of the Data API; the JWT is validated at the API,
pg_session_jwtprovidesauth.user_id()(textsub) andauth.uid()(uuid), rolesauthenticatedandanonymous(blog, Data API). - Nile: tenant isolation via
set nile.tenant_id = '...'on tenant-aware tables with a reservedtenant_id uuidcolumn instead of user-written policies (Nile). Translation target is tenant equality only. - PostgREST:
authenticatorrole,SET LOCAL ROLEfrom the role claim, claims inrequest.jwt.claims,db-pre-requesthook for custom validation (PostgREST auth). - ZenStack, as contrast:
@@allowand@@denypolicies compile into Kysely query-AST filters at the application layer (v3), database-agnostic, no migrations, and the project claims better performance than RLS; raw SQL, triggers and cascades are not covered (access control, querying, policy-handler.ts). PermDock offers both: app-sidewherefilters and RLS export, with parity tests as the glue.
Existing translation tools and parsers
- No mainstream
supabase-rls-to-typescriptorrls-to-zodexists; searches surface onlysupazodandsupabase-to-zod, which consumegen typesoutput and therefore contain zero policy data (supazod). The RLS-to-app direction is unoccupied. - Schema as code: Atlas has first-class
policy "name" { on, as, for, to, using, check }blocks plusrow_security { enabled, enforced }, custom lint rules, andatlas schema testthat switches roles (Atlas v0.25, HCL reference, RLS guide). Bytebase treats policies as ordinary SQL under review workflows (Bytebase). Sqitch is plain SQL;CREATE POLICYlacksIF NOT EXISTS, so idempotent scripts useDROP POLICY IF EXISTSor aDOblock guarded by apg_policieslookup (Stack Overflow). - Basejump (a Supabase SaaS starter with account and membership RLS) ships supabase-test-helpers:
tests.create_supabase_user(),tests.authenticate_as(),tests.authenticate_as_service_role(),tests.clear_authentication(),tests.rls_enabled(schema[, table]),tests.freeze_time().supa_auditis an audit-trail extension, not policy tooling. - Parsers for
qualandwith_check:libpg-queryand pgsql-parser (the real Postgres parser compiled to WASM, symmetric parse and deparse,@pgsql/types), @pgsql/parser (runtime-selectable PG 15 to 18 grammars),pg-query-emscripten(older predecessor), and pgsql-ast-parser (pure TypeScript,astVisitor,astMapper,toSql, no PL/pgSQL, incomplete coverage). Becausequalis a bare expression, parseSELECT 1 WHERE <qual>and take thewhereClausenode. Prefer libpg_query-based parsing for fidelity and use its deparser to produce a canonical fingerprint for drift detection.
Testing RLS
- pgTAP via
supabase test db(files undersupabase/tests/,begin; select plan(n); ... select * from finish(); rollback;) (testing, pgTAP). Structural asserts:policies_are(schema, table, names[]),policy_roles_are,policy_cmd_is,has_table_privilege. Behavioural:set local role authenticated; set local request.jwt.claim.sub = '<uuid>'(orset request.jwt.claims to '{"role":"authenticated","sub":"..."}'), thenresults_eq,is_empty,throws_ok(..., '42501'). - Match the assertion to the denial mode: a missing grant means
throws_ok 42501; aWITH CHECKfailure meansthrows_ok 42501; aUSINGfilter meansis_emptyover a statement withRETURNING, followed by a read proving the target row is intact. Never prove an allowed write withlives_ok, which passes on zero rows. - Alternatives: Atlas
schema test(execblocks under non-privileged roles), KyseracreatePolicyTester(app-side only), or any Node runner usingpgwith the sameset localpreamble inside a rolled-back transaction.
Design recommendation: the portable subset
Model conditions as a small typed tree over subject (id, claims, roles, permissions), row (the current row, RLS USING) and the next row (RLS WITH CHECK). The subset that round-trips:
| Portable node | Supabase SQL | Neon or generic GUC |
|---|---|---|
eq(row.user_id, subject.id) | (select auth.uid()) = user_id | (select auth.user_id()) = user_id or current_setting('app.user_id', true)::uuid = user_id |
eq(row.tenant_id, subject.claim('tenant_id')) | tenant_id = ((select auth.jwt()) ->> 'tenant_id')::uuid | tenant_id = current_setting('app.tenant_id', true)::uuid |
eq(subject.claim('user_role'), 'admin') | ((select auth.jwt()) ->> 'user_role') = 'admin' | GUC variant |
claimContains('app_metadata.teams', row.team_id) | team_id in (select jsonb_array_elements_text((select auth.jwt())->'app_metadata'->'teams'))::uuid | none |
memberOf(join_table, { resource_col, user_col, extra eq or in }) | id in (select resource_id from memberships where user_id = (select auth.uid()) [and role in (...)]) | same |
hasPermission('channels.delete') | (select authorize('channels.delete')) | opaque unless a function is mapped |
eq(row.is_public, true), eq(row.status, 'published'), isNull, isNotNull, true, false, and, or, not | literal SQL | literal SQL |
Import recognises both EXISTS (select 1 from m where m.res_id = t.id and m.user_id = auth.uid()) and the IN (subselect) form as memberOf; generate emits the IN form (Supabase's performance guidance) or a security definer helper for hot tables. Everything else (now() and interval arithmetic, CASE, multi-join subqueries, current_user, custom functions, claim checks beyond simple equality such as aal) becomes an opaque({ sql, fingerprint }) node: kept verbatim for regeneration, unusable for app-side can(), and flagged in the catalog. In PermDock's public vocabulary these nodes are written with the operators on the conditions page (eq, in, and, or, not, subject.<field>, subject.context.<key>); the table above uses the research report's working names.
Semantic mapping rules
| PermDock | Postgres |
|---|---|
read grant | FOR SELECT USING (cond) |
create grant with check | FOR INSERT WITH CHECK (cond on new row) |
update grant with where and check | FOR UPDATE USING (where on current row) WITH CHECK (check on new row); a grant that only mentions the current row collapses to USING only, because Postgres reuses it |
delete grant | FOR DELETE USING (cond) |
allow | AS PERMISSIVE |
deny | AS RESTRICTIVE with NOT (cond) |
| role | a claim, never a Postgres role; default TO authenticated; public-visible permissions TO anon, authenticated; service_role never emitted because it bypasses RLS |
- Import splits
FOR ALLinto four catalog entries; export merges them only if identical. - Generate a SELECT policy, or verify that
readcoversupdateanddelete, whenever update or delete permissions exist, because Postgres requires SELECT access to filter and for RETURNING. - Warn if a table ends up with zero permissive policies for a role and command.
- Import maps
roles = {public}to all subjects and non-Supabase role names to opaque role constraints. - Always emit the companion
ENABLE ROW LEVEL SECURITY, optionalFORCE, and grant and revoke statements; emit(select ...)wrappers and index suggestions for every filtered column.
The permdock rls CLI
generate --target drizzle|sql|prisma --dialect supabase|neon|guc: portable conditions becomepgPolicy(...)entries (withauthenticatedRole,authUid) or a raw migration (drop policy if existspluscreate policy, plus grants and indexes); the Prisma 8 target emitspolicy_*blocks with@@rls. Named permissions compile toauthorize()when--rbac supabaseis set, and the CLI can also emit theuser_roles,role_permissionsand hook scaffolding.import --db <url>or--sql <dump>: readpg_policiespluspg_class.relrowsecurity, parsequalandwith_checkwithpgsql-parser, pattern-match to portable nodes, and writesrc/permissions.generated.tsplus a catalog (table, command, permissive, roles, condition or opaque, fingerprint, source SQL). Deparse-normalised fingerprints, not raw text, detect drift on re-import.verify --db <url>: for each fixture of subject, row, optional next row and action, computecan()in-process, then run the operation insideBEGIN ... ROLLBACKwithset local role,set_config('request.jwt.claims', ..., true)andset_config('request.jwt.claim.sub', ..., true), usingRETURNING, and classify the outcome asallowed,filtered(zero rows) orrejected(42501). Emit as pgTAP forsupabase test dbor run directly viapgin Node; fail on any mismatch and report opaque policies as untestable app-side.
Risks to document
- Semantic drift: SQL three-valued logic (
auth.uid()NULL, nullable columns) versus JavaScript booleans; type casts (uuidversustextsub); JWT claim staleness versus live app lookups;user_metadatabeing user-writable. USINGversusWITH CHECKconfusion (the classic "user reassignsuser_id" hole),ALLpolicies hiding intent, the SELECT prerequisite for UPDATE and DELETE, RETURNING interactions.- Grants:
42501from a missing grant masquerades as a policy denial; codegen must own grants too. - Role mapping mismatch between app roles and the three Supabase roles;
service_role,bypassrlsand table owners (withoutFORCE) skipping policies entirely; views defaulting tosecurity definer(usesecurity_invoker = true). - Performance: unwrapped helper calls, unindexed filter columns, correlated joins, many permissive policies, recursion (
42P17); surface Splinter andget_advisorsfindings inverify. - Round-trip stability:
pg_get_exprrewrites text, so import-then-generate must compare ASTs, and migrations must be idempotent (DROP POLICY IF EXISTS, or delegate diffing to drizzle-kit, Prisma 8 or Atlas).
Adopt / adapt / avoid
Adopt:
- The Supabase RBAC scaffold (
user_roles,role_permissions,custom_access_token_hook,authorize()) as the compile target for named permissions. - Drizzle
pgPolicyplusdrizzle-orm/supabasehelpers, raw SQL, and Prisma 8policy_*blocks as the three generate targets. pg_policiespluspgsql-parserfor import, with deparsed-AST fingerprints for drift.- pgTAP and
supabase-test-helpersconventions for behavioural tests; theallowed/filtered/rejected 42501classification. - Supabase performance rules baked into generated SQL:
(select ...)wrappers,TO authenticated,IN (subselect)over joins, index suggestions. - Kysera's dual-mode idea (one definition, app-side and native), extended to both directions.
Adapt:
where(current row) andcheck(next row) onupdateandcreategrants mirrorUSINGandWITH CHECK; the same words drive Drizzle, Prisma and Kyselywherecompilers.- Symbolic
subject.*references instead of concrete values so one grant compiles toauth.uid(), a GUC, or a bound runtime value. opaquenodes for everything outside the portable subset, so imported policies regenerate byte-for-byte while the catalog marks them as untestable app-side.
Avoid:
- Emitting
service_rolepolicies or trustinguser_metadata. - Mapping app roles to Postgres roles.
- Comparing policy text instead of ASTs.
lives_okas proof of an allowed write.- Relying on
supabase gen typesor the Supabase MCP server for policy data; neither exposes policies.
Decisions informed
- ADR 0010: policy as data, portable conditions
- ADR 0008: plain JSON leaves, identity by key (generated definitions merge by key)
- ADR 0003: reference-based permissions (named permissions compile to
authorize('key')) - Pages shaped: conditions, policies, rls, drizzle, prisma, kysely, supabase, rls CLI, Postgres RLS standard, larger apps, testing, threat model.
@zap-studio/permit deep-dive
Source study of @zap-studio/permit 2.0.1, the only authorization library built on Standard Schema, and what PermDock adopts, adapts and avoids from its schema-first resources and fail-closed evaluation.
Next.js 16.3 Instant Navigations
What the Next.js 16.3 App Shell, 'use cache: private', Partial Prefetching and the instant() test helper mean for a permissions library, and the design consequences for permdock/next.