Postgres row-level security
Postgres RLS as a compile target and import source for PermDock policies, covering CREATE POLICY semantics, Supabase helpers, GUC patterns, pg_policies introspection, and the Drizzle and Prisma 8 authoring surfaces.
Status: planned
Phase: 3
Adapter phases: permdock rls (CLI) 3, permdock/supabase 3, permdock/drizzle, permdock/prisma and permdock/kysely 3.
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 are folded into whichever command is being evaluated. - Clause legality.
SELECTtakes onlyUSING;INSERTtakes onlyWITH CHECK;DELETEtakes onlyUSING;UPDATEandALLtake both, and ifWITH CHECKis omittedUSINGis reused for the new row. - Cross-command coupling.
UPDATEandDELETEthat read columns also need a passingSELECTpolicy, andRETURNINGrows must satisfy theSELECTpolicy. - Denial modes.
USINGsilently filters (zero rows);WITH CHECKraises42501; a missingGRANTraises42501before any policy runs. - Roles and grants are separate from policies. Default
TO PUBLIC; policies run with the caller's privileges.
Introspection is through the pg_policies view (schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check), backed by pg_policy. The qual and with_check columns are deparsed with pg_get_expr, which returns normalised SQL (explicit casts, ( 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 via SET LOCAL ROLE from the JWT role claim; service_role has bypassrls. Helpers: auth.uid() (NULL when unauthenticated) and auth.jwt() (the claims as jsonb; read app_metadata, never user-writable user_metadata). auth.role() is deprecated in favour of the TO clause. Claims are exposed as the GUCs request.jwt.claims and request.jwt.claim.sub, which tests and the Drizzle createDrizzle wrapper set. The performance guide says to wrap helpers as (select auth.uid()), index filtered columns, always write TO authenticated, and prefer IN (subselect) over correlated joins. Supabase's RBAC pattern (user_roles, role_permissions, a custom access token hook and an authorize(permission) function used as using ((select authorize('channels.delete')))) is a named-permission catalog in SQL.
Generic Postgres and Neon
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 provides auth.user_id() and auth.uid() with roles authenticated and anonymous.
Authoring surfaces
- Drizzle:
pgPolicy(name, { as, to, for, using, withCheck })in a table definition,drizzle-orm/supabase(authenticatedRole,anonRole,authUid,createDrizzle),drizzle-orm/neoncrudPolicy;drizzle-kit generatediffs policies. - Prisma 8 (changelog, prisma-next#945):
@@rlson a model, top-levelpolicy_select|insert|update|delete|allblocks withtarget,roles,using,withCheck,roledeclarations, anddb verifyfor policy drift. - Parsers:
pgsql-parser(libpg_query in WASM, symmetric parse and deparse) and@pgsql/parser; sincequalis a bare expression, parseSELECT 1 WHERE <qual>and take the where clause. - Testing: pgTAP via
supabase test dbwithpolicies_are,policy_roles_are,policy_cmd_is, andset local roleplusrequest.jwt.claimsfor behavioural tests.
Why it matters for PermDock
RLS is the only enforcement layer that survives a bypassed application, and Supabase users already write it. But a policy written twice (once in SQL, 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 can be imported back into a typed catalog. No existing tool does the import direction; Kysera's @kysera/rls is the only dual-mode prior art and generates one direction for raw-SQL policies only. See the rls adapter, CLI rls and the RLS research.
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:
allowisPERMISSIVE,denyisRESTRICTIVEwithNOT (cond);readisSELECT USING,createisINSERT WITH CHECKon the new row,updateisUSING(current row, fromwhere) plusWITH CHECK(next row, fromcheck),deleteisDELETE USING. ASELECTpolicy is generated or verified whenever update or delete grants exist. DefaultTO authenticated;service_roleis never emitted. CompanionENABLE ROW LEVEL SECURITY, grant and revoke statements,(select ...)wrappers and index suggestions are emitted alongside. With--rbac supabase, named permissions compile toauthorize('post.delete')and theuser_roles/role_permissions/ hook scaffold is generated. - Import.
pg_policiespluspg_class.relrowsecurityare read,qualandwith_checkparsed withpgsql-parser, and pattern-matched to portable nodes; anything else becomesopaque({ sql, fingerprint }), kept verbatim for regeneration and flagged in the catalog. Fingerprints come from the deparsed AST, not raw text, sopg_get_exprnormalisation does not register as drift.FOR ALLis split into four entries. The output is a deterministicdefinePermissions()file that merges with hand-written ones. - 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', ...); outcomes are classifiedallowed,filtered(zero rows) orrejected(42501) and any mismatch fails. Emitted as pgTAP or run from Node; opaque policies are reported as untestable app-side.
Mapping table
| Postgres / Supabase concept | PermDock concept |
|---|---|
allow(permission, { where }) | AS PERMISSIVE ... USING (cond) |
deny(permission, { where }) | AS RESTRICTIVE ... USING (NOT (cond)) |
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) |
delete action | FOR DELETE USING |
eq(row.user_id, subject.id) | (select auth.uid()) = user_id or current_setting('app.user_id', true)::uuid = user_id |
eq(row.tenant_id, subject.claim) | tenant_id = ((select auth.jwt()) ->> 'tenant_id')::uuid or a GUC |
| Membership via join table | id in (select resource_id from memberships where user_id = (select auth.uid())); EXISTS recognised on import |
| Named permission | (select authorize('post.delete')) with --rbac supabase |
| App roles | JWT claims, not Postgres roles; policies default TO authenticated |
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 |
Drizzle pgPolicy | --target drizzle output |
Prisma 8 policy_* block, @@rls | --target prisma output |
Sources
- PostgreSQL CREATE POLICY, pg_policies, pg_policy.
- Supabase RLS guide, RLS performance, Custom claims and RBAC, Testing.
- Drizzle RLS, Neon RLS with Drizzle.
- Prisma 8 changelog, prisma-next#945.
- pgsql-parser, @pgsql/parser.
- Kysera multi-tenancy as prior art.
Open questions
- SQL three-valued logic versus JavaScript booleans (
auth.uid()NULL, nullable columns) anduuidversustextcasts: how farverifyshould go in surfacing these as warnings rather than mismatches. - Whether
generateshould own migrations (idempotentDROP POLICY IF EXISTSplusCREATE POLICY) or always delegate diffing to drizzle-kit, Prisma 8 or Atlas. - Whether opaque conditions should be evaluable app-side through a user-supplied closure keyed by fingerprint, or stay server-only.
- Which Splinter /
get_advisorsfindingsverifyshould fail on versus report.
RFC 9457 Problem Details
The application/problem+json body PermDock's HTTP adapters return for denied and approval-required decisions, and why it is written for humans and models alike.
JWT authorization claims (RFC 9068, SCIM)
How PermDock reads the registered roles, groups and entitlements JWT claims (RFC 9068 section 2.2.3.1, SCIM RFC 7643 encoding) into global roles, team memberships and entitlement roles, how vendor tenant claims map to the active tenant, and the AuthZEN claims draft that makes a PDP a claim source.