Postgres RLS
permdock rls generate, import and verify round-trip PermDock policies and Postgres row-level security across Drizzle, raw SQL and Prisma 8 targets for Supabase, Neon and generic Postgres.
permdock rls is the CLI surface (with a permdock/rls runtime for the parity runner) that compiles roles and grants to Postgres row-level security policies, imports existing policies back into generated definitions, and verifies that in-process decisions and database outcomes agree.
Database-first (SQL is the authority)
TypeScript-first is the default: generate emits policies from definePolicy. Teams whose SQL catalog is authority (declarative supabase/schemas, helper functions, CI that must not db push) keep that catalog. They never have to run generate. They pin a policy with sqlFunction twins, map names in rls.functions, and run permdock rls verify --db so can() on the twin agrees with the live function. --inline-functions is for generate targets that cannot call a SQL function; it is not required for database-first CI.
pnpm exec permdock rls verify --db $DATABASE_URL --fixtures rls.fixtures.jsonverify exits 1 on any mismatch. Opaque grants are reported as untestable app-side. sqlFunction grants are reported as verified through twin. Doctor PD016 warns when an rls config still has opaque grants or sqlFunction grants with no fixtures.
Purpose
A permission such as allow(permissions.post.update, { to: relation(permissions.post, 'author') }) (or { where: { authorId: principal.id } }) should be true in the UI, in the API and in the database. Today teams hand-write the RLS twin of every app rule and the two drift. No existing tool goes from RLS to application permissions, and the only dual-mode prior art (Kysera @kysera/rls) generates one way from raw SQL (Postgres RLS). permdock rls makes the portable condition the single source: generate emits policies, import reads them back (portable where possible, opaque otherwise), and verify proves parity with fixtures.
API
permdock rls generate --target drizzle|sql|prisma --dialect supabase|neon|guc [--rbac supabase] [--authorize database|jwt] [--memberships <table>[:tenant_col,user_col,role_col]] [--tenant-type uuid] [--policy-per-role] [--policy-name '{table}_{op}'] [--fields views [--revoke-columns]] [--out <path>]
permdock rls import --db $DATABASE_URL | --sql schema.sql --out src/permissions.generated.ts [--schema zod|valibot|arktype] [--from drizzle]
permdock rls verify --db $DATABASE_URL [--fixtures rls.fixtures.ts] [--format pgtap|node] [--tree]Semantics the generator applies:
| PermDock | Postgres |
|---|---|
read (instance action) | FOR SELECT USING (cond) |
create (collection action with check) | FOR INSERT WITH CHECK (cond on new row) |
update | FOR UPDATE USING (where on current row) WITH CHECK (check on new row; defaults to where) |
delete | FOR DELETE USING (cond) |
allow | AS PERMISSIVE, one policy per table and command ({table}_{op}) |
deny | AS RESTRICTIVE with NOT (cond), one policy per table and command (deny_{table}_{op}) |
| global role | (select <schema>.permdock_has('<grant key>')) |
| role on a named scope | "<scope key>" in (select <schema>.permitted_<scope>_ids('<grant key>')) |
| relation through the parent chain | "<field>" in (select descendant from <schema>.permdock_closure where resource = '<resource>' and ancestor = any (array(select <schema>.permitted_<resource>_ids('<relation>')))) (closure table) |
| role, no public grant | TO authenticated (default) |
anyone() grant | ORed into the TO authenticated policy, and a {table}_{op}_anon policy TO anon (anonymous on neon) |
Supabase anonymous sign-in, with rls.anonymousSignIns: 'deny' | and ((select auth.jwt()) ->> 'is_anonymous') is distinct from 'true' on every branch but anyone()'s |
service_role | never emitted in a policy or helper, since it bypasses RLS; only rls.trustedReaders may name it, for the execute grant on permdock_trusted_role_permissions |
rls.realtime topic, rls.storage bucket (Supabase) | Policies on realtime.messages and storage.objects keyed by a topic segment or folder (Realtime and Storage) |
Additional rules: a SELECT policy is generated (or its coverage asserted) whenever update or delete grants exist, because Postgres requires SELECT access to filter and for RETURNING; ENABLE ROW LEVEL SECURITY is always emitted, before the grants, and every table is schema-qualified (public when the mapping names none); REVOKE ALL ... FROM anon, authenticated followed by the exact GRANTs is emitted next to the policies, because a missing grant raises 42501 and masquerades as a policy denial; helper calls are wrapped as (select permdock.permdock_user_id()) so they run once per statement; an index suggestion is printed for every filtered column, or written as the indexes part of rls generate --split. Supabase gives an anonymous sign-in the authenticated role, so the grants to roles and to authenticated() reach it; rls.anonymousSignIns: 'deny' keeps a token with is_anonymous: true to the anyone() grants, and subjectFromSupabase(claims, { anonymousSignIns: 'deny' }) refuses it in process too; permdock doctor PD057 names a subject call without it. Grants come from definePolicy({ roles }) and top-level definePolicy({ grants }) alike, and a grant Postgres cannot enforce (a plan() or assurance() grantee, or an actor() grantee other than the one below) fails generation with the permission named. deny(permission, { to: actor('oauth-client') }) on its own compiles, because the token shows a delegated client: it becomes a RESTRICTIVE policy that refuses rows while client_id or act is in the claims, so a third-party app's token cannot reach what the user's own session can. contains on a column whose resource schema says it is an array compiles to value = any(column), with the value cast to the item type; on any other column it is LIKE with \, % and _ in the value escaped, so the pattern matches literally, as the in-process where does.
Per-statement helpers (InitPlan)
The role check of every grant lives in a generated function, never in the per-row part of a policy:
<schema>.permdock_has(p_grant text) returns boolean: the subject holds a global role with the grant key.<schema>.permitted_<scope>_ids(p_grant text) returns setof <scope type>, one per named scope the policy declares (permitted_tenant_idsandpermitted_team_idswhen it declares none): the instances of that scope in which the subject holds it, through memberships of exactly that scope.<schema>.member_<scope>_ids() returns setof <scope type>, one per declared scope: the instances of that scope the subject holds any live membership of. It takes no permission key, for policies such as "a member may read their organization's row".<schema>.member_<scope>_ids_for(p_user uuid) returns setof <scope type>, Supabase only, for each scope with a membership source (rls.membershipSourcesorsupabase.hook.memberships) or a mappedrls.membershipstable: the same answer asmember_<scope>_ids()forp_user, read from the membership tables in both modes. It is for code that runs before a token exists, such as asupabase.hook.claimsfunction:executeis revoked frompublic,anonandauthenticated, and only the hook migration grants it, tosupabase_auth_admin.<schema>.permdock_has_for(p_user, p_grant text) returns booleanand<schema>.permitted_<scope>_ids_for(p_user, p_grant text) returns setof <scope type>,databasemode only: whatpermdock_hasandpermitted_<scope>_idsanswer forp_user(auuidon Supabase,texton the other dialects), read from the same tables with the same expiry and suspension filters and no active-tenant narrowing, since a named user has no token. They are for trusted SQL that acts for a stored user: a job, a trigger, an approval decided after the request.executeis revoked frompublic,anonandauthenticated; asecurity definerfunction runs them as its owner, and a backend role needs a grant of its own (grant execute on function permdock.permitted_organization_ids_for(uuid, text) to service_role).
They join the subject's roles (the user_roles table in database mode, the role claim in jwt mode) or memberships (the memberships table, or the memberships claim) with a generated <schema>.role_permissions (role, permission, grant_key, scope, effect) table that generate seeds from the policy. The grant key is the permission key; roles that grant one permission under different portable conditions get one key per condition group (post.update#1, post.update#2), and each group is one OR branch of the policy with its condition ANDed on. relation, principal.id and where conditions stay in the policy because they depend on the row.
Postgres plans an uncorrelated scalar subquery as an InitPlan and an uncorrelated IN (select ...) as a hashed SubPlan: both run once per statement and the per-row filter only reads the result. Any expression that mentions a column of the row, such as authorize('post.read', "orgId"::text), is correlated and runs once per row, however it is wrapped. On 10,000 rows across 20 tenants, as a member of one tenant, the benchmark in tests/integration/bench measures (Postgres 16):
-- before: per-row authorize() plus a membership join per role
Seq Scan on post (actual rows=500 loops=1)
Filter: ($0 OR $1 OR ((SubPlan 3) AND (hashed SubPlan 9)) OR ...)
SubPlan 3
-> Result (actual rows=1 loops=10000) -- authorize(..., "orgId"::text), per row
Execution Time: ~1600 ms
-- after: helper calls, uncorrelated
Seq Scan on post (actual rows=500 loops=1)
Filter: ($0 OR (hashed SubPlan 2) OR $2 OR (hashed SubPlan 4))
InitPlan 1 (returns $0)
-> Result (actual rows=1 loops=1) -- permdock_has('post.read')
SubPlan 2
-> ProjectSet (actual rows=1 loops=1) -- permitted_tenant_ids('post.read')
Execution Time: ~1 msThe benchmark asserts on EXPLAIN (ANALYZE, FORMAT JSON) that every node calling a helper has loops = 1 (whether Postgres labels it an InitPlan or a hashed SubPlan), that both shapes return the same rows, and that the helper shape is at least 5x faster with an absolute ceiling.
Security notes on the helpers:
- They are
security definerso they can readrole_permissions,user_rolesand the memberships table, whichauthenticatedcannot select; the policy never needs a grant on those tables, and their own RLS does not recurse into the helper. set search_path = ''and fully qualified names stop a caller from shadowing a table or function throughsearch_path.- They are
stable, take only the grant key, and read the subject inside the function (on Supabase throughpermdock_user_id(), which answers asauth.uid()does and returns null for an emptysub; see the dialects) and the claims: a caller cannot pass another user's id.member_<scope>_ids_for(p_user),permdock_has_forandpermitted_<scope>_ids_fortake a user, so no client role may execute them. executeis revoked frompublicandanonand granted toauthenticated, soanonpolicies (anyone()grants) never reference them. The exceptions are ananonpolicy that reads the subject, which callspermdock_user_id(), and a field viewanonreads: Postgres checksexecuteon every function a view calls before it runs, so the helpers are granted toanontoo. They find no subject foranon(nosub, no role claim, no membership row) and return nothing. A hand-written policy that calls a helper saysto authenticated: a policy forpublicoranonmakes Postgres checkexecuteforanontoo, the statement fails, and an InitPlan over a function the role may not execute has crashed the backend (signal 11) on some Postgres builds. When such policies must stay,rls.anonExecute: truegrantsanonusage on the helper schema andexecuteon every helper, the same grant field views get.role_permissionshas RLS enabled and every privilege revoked fromanon,authenticatedandpublic; it changes only through a migration that re-runsgenerate.
SQL helper contract
Other packages call the helpers from policies PermDock does not generate: better-supabase's Storage and Realtime policies do through the provider from permdock/better-supabase, and an app's own RPCs may. These signatures, the claims they read and the rules below are the contract; they change only with a new major of the hook marker (-- permdock:hook v1) and the hook manifest.
| Function | Returns | Answers |
|---|---|---|
<schema>.permdock_has(p_grant text) | boolean | The caller holds a global role whose grants include p_grant |
<schema>.permitted_<scope>_ids(p_grant text) | setof <scope type> | The instances of <scope> in which the caller holds a role that grants p_grant, through memberships of exactly that scope; with the active-tenant claim set, only inside that tenant unless rls.tenants is 'all' |
<schema>.member_<scope>_ids() | setof <scope type> | The instances of <scope> the caller holds any live membership of, with any role; not narrowed to the active tenant |
<schema>.member_<scope>_ids_for(p_user uuid) | setof <scope type> | What member_<scope>_ids() answers for p_user, read from the membership sources: expired memberships, a suspended user and suspended instances drop out. Executable by supabase_auth_admin only, for supabase.hook.claims functions |
<schema>.permdock_has_permission(p_permission text) | boolean | The caller holds an unconditional global allow of the permission and no global deny of it |
<schema>.permitted_<scope>_ids_by_permission(p_permission text) | setof <scope type> | The instances of <scope> an unconditional allow of the permission reaches, minus those a deny of it reaches |
<schema>.permitted_<scope>_ids_by_permission(p_permission text, p_conditioned boolean) | setof <scope type> | With true, also the instances a conditioned allow of the permission reaches, minus only the instances an unconditional deny reaches; the caller applies the row condition |
<schema>.permitted_<scope>_permission_keys(p_id <scope type>) | setof text | Every permission key the caller holds on one instance of <scope>: the keys for which permdock_has_permission or permitted_<scope>_ids_by_permission admits p_id, in one call; authenticated may execute it |
<schema>.permitted_<scope>_permission_keys(p_id <scope type>, p_keys text[]) | setof text | The same, limited to the keys in p_keys (a former key counts as its current one); authenticated may execute it |
<schema>.permitted_<scope>_permission_keys_for(p_user, p_id <scope type>) | setof text | The same keys for p_user; database mode, executable by no client role |
<schema>.grant_keys(p_permission text, p_scope text, p_effect text) | setof text | The grant keys of the permission's unconditional allows on p_scope, every deny key with p_effect 'deny', and the keys of allows or denies with a row condition with 'conditioned-allow' or 'conditioned-deny' |
<schema>.permdock_role_permissions(p_role, p_scope, p_tenant, p_scope_id) | table | Rows (permission, effect): the keys p_role holds on p_scope, allow or deny; a declared role or, in database mode, a custom role of the tenant; authenticated may execute it |
<schema>.permdock_trusted_role_permissions(p_role, p_scope, p_tenant, p_scope_id) | table | The same rows without the caller check, for server code; no client role may execute it, and rls.trustedReaders grants it to the roles it lists |
<schema>.permdock_permission_keys() | setof text | Every declared permission key, sorted; authenticated may execute it |
<schema>.permdock_permission_keys(p_scope text) | setof text | The declared keys some declared role is allowed on p_scope (global or a scope name), sorted; authenticated may execute it |
<schema>.permdock_has_for(p_user, p_grant text) | boolean | What permdock_has answers for p_user; database mode, executable by no client role |
<schema>.permitted_<scope>_ids_for(p_user, p_grant text) | setof <scope type> | What permitted_<scope>_ids answers for p_user, never narrowed to an active tenant; database mode, executable by no client role |
<schema>.permitted_<resource>_rows(p_permission text) | setof text | With rls.rowHelpers: the ids of <resource>'s rows the caller may act on with p_permission, decided by the same allows and denies as the generated policies (row helpers); authenticated may execute it |
<schema>.permitted_<resource>_row(p_row <table>, p_permission text) | boolean | With rls.rowHelpers: whether the caller may act on the row value p_row with p_permission, by the same allows and denies, read from its columns instead of looked up by id (row helpers); authenticated may execute it |
<schema>.permitted_<resource>_rows_for(p_user, p_permission text, p_claims jsonb) | setof text | The same rows for p_user, with p_claims as the rest of the token; not in the neon dialect, executable by no client role |
<schema>.permitted_<resource>_ids(p_relation text) | setof text | The ids of <resource> the caller holds p_relation on in the relationship graph, every graph relation when p_relation is null |
- Grant keys.
p_grantis a permission key (invoice.read). PermDock's own policies passinvoice.read#1,invoice.read#2for the condition groups of one permission; the part after#is positional by default, so it can change when a role's grants change. SQL that does not apply a condition itself calls the permission-key forms below instead of spelling a grant key. SQL that repeats a condition names the group in the policy,allow(permissions.invoice.read, { where: { status: 'sent' }, group: 'sent' }), and callspermitted_<scope>_ids('invoice.read#sent'): a named key stays the same when other grants are added, removed or reordered, and the unnamed groups keep their positional keys.generaterefuses one name on two conditions of a permission, and two names on one condition. - Permission keys.
permdock_has_permission(p_permission)andpermitted_<scope>_ids_by_permission(p_permission)take a permission key (a former key fromdefinePermissionsrenamedmaps to the current one) and answer with the grant keys of its unconditional allows on that scope, minus any instance a deny of the permission reaches, conditional or not. A permission whose allows on a scope all carry a row condition, a validity window or a break-glass override answers nothing there, so these forms never grant more than the policy's unconditional rows.grant_keys(p_permission, p_scope, p_effect default 'allow')lists those keys ('deny'lists the deny keys,'conditioned-allow'and'conditioned-deny'the keys that carry a row condition) for SQL that passes them to another helper. A field list (fields) is not a row condition: an allow limited to some fields still lists its instances, here and inpermittedIds.permitted_<scope>_ids_by_permission(p_permission, true)also lists the instances a conditioned allow reaches and subtracts only the instances an unconditional deny reaches, for a nested scope whose grants all carry a condition (portal contacts who may read only sent quotes): the result says where some rows may be allowed, and the query still applies the condition. Indatabasemodepermdock_has_permission_for(p_user, p_permission)andpermitted_<scope>_ids_by_permission_for(p_user, p_permission)answer for a named user, executable by no client role. A screen or RPC that checks many permissions on one organization callspermitted_<scope>_permission_keys(p_id)once instead of one helper call per key: it returns each keypermdock_has_permissionorpermitted_<scope>_ids_by_permissionwould admit for that instance, global grants included, with the same rule that a conditioned allow counts for nothing. A grant at another scope is not read for this one, as withpermitted_<scope>_ids_by_permission;permitted_<scope>_permission_keys_for(p_user, p_id)answers for a named user indatabasemode. Both take an optional second list,p_keys, to answer for a few keys only. They read the roles, memberships, custom roles and grants once for all keys in one query, so asking for every key of a 190-key catalog costs a few milliseconds, where a call per key costs a helper evaluation each (tests/integration/src/rls-permission-keys-bench.test.tsholds it under a third of the per-key time). - Role readers.
permdock_role_permissions(p_role, p_scope, p_tenant default null, p_scope_id default null)answers which permission keys a role holds, for a role editor or an admin screen that would otherwise hand-write the join.p_tenanthas the type of the tenant column (rls.tenantType,uuidby default). A declared role answers fromrole_permissionswhatever the other arguments, which is the catalog; the effect isdenyfor a deny grant of the role, and a permission with several grants is listed once per effect, whatever their row conditions. Withrls.customRolesindatabasemode, any other role name is a custom role:p_tenantnames its tenant,p_scopeits scope andp_scope_idits instance (null at the first scope), and the answer is whatresolveCustomRolereturns, so a stored entry outside the ceiling never appears and a deny of the role itself only removes an allow. Only a member of the tenant (or of the instance below the first scope) may read a tenant's custom role, and a platform role (p_scopeglobal) only a holder of ameta.manageRolespermission; anyone else gets SQLSTATE42501, and a scope or name that cannot hold a custom role22023. Injwtmode custom roles live in claims, so an undeclared name answers nothing.permdock_permission_keys()lists the catalog's keys without a role, andpermdock_permission_keys(p_scope)only the keys some declared role is allowed on that scope (global, or a scope name), so a role editor at the organization offers only what an organization role can hold. Neither function is executable byanon. Server code that reads any tenant's roles (an admin backend, a support console on a read-only role, a job) callspermdock_trusted_role_permissionswith the same arguments: the same answer and argument checks, without the member check. It is revoked frompublic,anonandauthenticated, andrls.trustedReaders: ['service_role', 'support_reader']grants it to those Postgres roles. - Role and scope only. The helpers check the role and the scope and nothing else: no
where,check, relation, plan, actor or assurance. A call is correct only for a permission whose grants carry no other condition. The catalog marks each permission withrowConditions,truewhen a code grant has awhere,check, closure, field list, purpose, break-glass override,validFrom/validUntilwindow, or a relation, plan, actor or assurance grantee.permdock doctorPD037 andpermdock rls verify --dbrefuse a Storage or Realtime policy that calls a helper for arowConditions: truekey. - Exact-text ids. Scope ids compare as exact text: the helpers never apply
lower()or trim, and neither doesdecide. A producer writing ids into a claim or a membership table writes the id's canonical text,uuid::textfor a uuid column (Postgres prints it in lower case). An upper-case uuid in a claim is a different id todecide, while auuidcast in Postgres would still match it, so the application and the database would disagree. - Claims. In
jwtmode the helpers readuser_role,membershipsand the active-tenant claim (tenant_idby default,rls.tenantClaim) fromauth.jwt(), top-level or underapp_metadata.schemas/supabase-claims-v1.jsonin thepermdockpackage is their JSON Schema: a membership is{ scope, id, within?, roles, via?, expiresAt?, grantedBy?, reason? }with a non-emptyroles, and the hook also writesmemberships_truncated,attrsandauthz_ver.permdock supabase hook generateis the one writer;permdock supabase inspect --jsonprints which claims it writes and where the helpers live.
Row helpers
rls.rowHelpers: true (or a list of resource names) writes permitted_<resource>_rows(p_permission) for each resource a grant reaches, with the resource name in snake_case. It returns the ids of the rows the caller may read, update or delete with that permission: one case arm per permission of the resource, the allows the generated policies would OR together for it, minus its denies. Closure walks, link hops, the restricted stop, includes, groups, requires and inherit() apply as they do in the policies, and an action with no SQL command (archive, review) gets an arm too, so a hand-written policy or a trusted function asks id in (select permitted_doc_rows('doc.archive')) instead of restating the graph. It works with --helpers-only, where it is the generated part a hand-written policy calls. Every resource an inherit() grant targets gets its helper whether or not rls.rowHelpers lists it, written first as an empty stub so helpers that call each other can be created in any order. An unknown key, a key of another resource or a create permission answers no rows.
permitted_<resource>_row(p_row, p_permission) answers the same question for one row value, reading its columns instead of looking up its id. A table's select policy that asks id in (select permitted_doc_rows('doc.read')) cannot see a row the same statement inserts, so insert ... returning fails with a row-level security error; using (permdock.permitted_doc_row(doc, 'doc.read')) passes the new row itself, and an insert policy's with check can call it the same way.
create policy doc_select on public.doc for select to authenticated
using (permdock.permitted_doc_row(doc, 'doc.read'));permitted_<resource>_rows_for(p_user, p_permission, p_claims default '{}') answers for a stored user. It sets the subject for the one call, transaction-local, and restores the previous values: request.jwt.claims with sub and role merged over p_claims, or <gucPrefix>.user_id and one setting per claim in the guc dialect. In jwt mode pass the claims the user's token would carry (memberships, tenant) in p_claims. No client role may execute it, and the neon dialect, whose subject comes from auth.session(), gets none.
Ownership rules
A role's for reaches the helpers, and the membership exists check of a resource role, as a kind check on each membership row (the table's via column or via: { value } constant, or the claim entry's via), so a staff role held through a contact membership selects no rows in the database either; a table with no via column holds a role with for for nothing. min, max and transferOnly become triggers on each scope's membership table, or on the tables of the fromTable / fromJunction sources that hold the scope: a deferred constraint trigger counts holders at commit, so a transfer in one transaction passes and removing the last owner fails with SQLSTATE 23514, and statement triggers over transition tables refuse a statement that changes a transfer-only role's holder count. permdock_can_assign(p_role, p_scope_id) answers the assigns graph for the application's own policies on those tables. decideRoleChange checks the same rules in the application; the triggers are the backstop for writes that skip it (ownership, CLI). tests/integration/src/named-scopes.test.ts and rls-ownership.test.ts run them against Postgres.
Custom roles
With --custom-roles, the scope helpers also resolve tenant-defined custom roles. In database mode they read custom_role_permissions (tenant_id, scope, scope_id, role, permission, effect) and custom_role_includes (tenant_id, scope, scope_id, role, include_role); in jwt mode, the grants map on each membership of the memberships claim. Either way, permdock_custom_keys turns the custom role into grant keys through the permdock_ceiling view, which holds only the keys of declared assignable roles and those roles' denies: allowed rows are unioned, denied permissions subtracted, included roles contribute their ceiling keys, and a row outside the ceiling reaches nothing. Platform custom roles (scope: 'global') are rows with a null tenant_id, or entries of the top-level role_grants claim, and permdock_has unions them into every check. The resolution is the same one resolveCustomRole runs in the application, so decide and the database agree; In database mode the helpers read the custom role's name from an rls.memberships table or from fromTable / fromJunction membership sources alike. tests/integration/src/rls-custom-roles.test.ts checks both database layouts and jwt mode with permdock rls verify --db and rlsParity.
Portable subset compiled by generate and recognised by import:
| Portable node | supabase | neon | guc |
|---|---|---|---|
eq(row.user_id, principal.id) | (select permdock.permdock_user_id()) = user_id | (select auth.user_id()) = user_id | (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 | same via auth.session() | (select current_setting('app.tenant_id', true))::uuid = tenant_id |
eq(principal.claim.user_role, 'admin') | ((select auth.jwt()) ->> 'user_role') = 'admin' | claim function | GUC read |
eq(row.region, principal.claims.attrs.region) (nested claim) | region = ((select auth.jwt()) -> 'attrs' ->> 'region') | same via auth.session() | region = (nullif((select current_setting('app.attrs', true)), '')::jsonb ->> 'region') |
lte(row.clearance, principal.claims.attrs.clearance) on a numeric column | clearance <= (case when jsonb_typeof(((select auth.jwt()) -> 'attrs' -> 'clearance')) = 'number' then ((select auth.jwt()) -> 'attrs' ->> 'clearance')::numeric end) | same via auth.session() | a flat setting is cast as is; a nested one like supabase |
in(row.region, principal.claims.attrs.regions) (array claim) | region = any (array(select (e #>> '{}') from jsonb_array_elements(<claim or '[]'>) e where jsonb_typeof(e) = 'string')); notIn is region is not null and not (region = any (...)) | same | the setting holds JSON text |
membership (in(row.id, context.teamIds)) | id in (select team_id from team_user where user_id = (select permdock.permdock_user_id())) | same | same |
sqlFunction('job_permitted', { args, twin }) | job_permitted("id") (--inline-functions emits the twin) | same | same |
{ subject: { session: { live: true } } } | (select permdock.permdock_session_live()); the helper finds the token's session_id in auth.sessions (live sessions) | Refused: rls generate exits 2 | Refused: rls generate exits 2 |
| A scoped role's grant | org_id in (select permdock.permitted_tenant_ids('post.read')), customer_id in (select permdock.permitted_customer_ids('quote.read')); the helper reads that scope's membership table (database) or the memberships claim (jwt) | same | same, user and claims from GUCs |
| A global role's grant | (select permdock.permdock_has('post.read')); the helper reads user_roles (database) or the role claim (jwt) | same | same |
A grant with validFrom / validUntil | now() >= to_timestamp(<from>) and now() < to_timestamp(<until>) ANDed into the grant's access check, on allow and deny branches alike; a one-sided window emits one comparison | same | same |
A custom role (--custom-roles) | No policy change: each permitted_<scope>_ids also returns the instances where a held custom role's keys, bounded by permdock_ceiling, include the grant key | same | same |
memberOf(tenant, row.org_id, roles) in a condition, no membership table | org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid (cast to --tenant-type) | same via auth.session() | current_setting('app.tenant_id', true)::uuid = org_id |
memberOf(tenant, row.org_id, roles) in a condition, with a membership table (supabaseRls({ memberships }) or --memberships <table>) | exists (select 1 from organization_members m where m.organization_id = org_id and m.user_id = (select permdock.permdock_user_id()) and m.role = any('{admin,viewer}')) | same | same, user from the GUC |
memberOf(<scope>, row.team_id, roles) for a scope under the first | exists (select 1 from team_members m where m.team_id = team_id and m.user_id = (select permdock.permdock_user_id()) and m.role = any(...)), over that scope's table, plus the active-tenant equality on its first-scope column | same | same |
memberOf(resource, row.id, roles) with declared parents | A resource role: one exists (...) per mapped resource on the chain, each over that resource's membership table and keyed by the row field holding its id (id for the resource itself, folder_id, project_id for ancestors); a resource with no mapped table contributes nothing | same | same |
related(folder.viewer, row.folderId, depth 16) (a through: 'parent' grant) | "folderId" in (select descendant::uuid from permdock.permdock_closure where resource = 'folder' and ancestor = any (array(select permdock.permitted_folder_ids('viewer')))); and depth <= n when the grant's depth is below the deepest walk of that resource; and "restricted" is not true when the row reaches the folder through its parent and declares restricted | same, subject from auth.user_id() | same, subject from the GUC |
related(folder.editor, row.id, depth 0) (an edge relation on the row's own resource) | "id" in (select p.id::uuid from permdock.permitted_folder_ids('editor') as p(id)) | same | same |
An edge relation with match, includes or groups | No policy change: permitted_folder_ids('viewer') adds and "role" = 'viewer' for match, unions the relations viewer includes, and for a group row reads the members of the group through a recursive CTE bounded at 16 levels | same | same |
related(team.lead, row.folderId) with through: ['folder', 'team'] | The link path from the grantee back to the row: "folderId"::text in (select permdock.permdock_link_folder_team(array(select permdock.permitted_team_ids('lead')))), one permdock_link_<resource>_<link> helper per hop | same | same |
(select authorize('channels.delete')) in a hand-written policy (with --rbac supabase) | imported as opaque | opaque | opaque |
literals, isNull, boolean columns, and / or / not | literal SQL; not renders as (…) is not true, so a comparison against a NULL column counts as false before it is negated, as in the evaluator | same | same |
memberOf is the node a tenant-, team- or resource-scoped role produces (tenancy); it is portable only when the generator knows where memberships live. The dialect option memberships names the table and columns per scope kind ({ tenant: { table, tenant, user, role }, team: {...}, resource: { document: {...} } }); with an active-tenant claim configured and no table, tenant scopes compile to the claim equality and team or resource scopes are reported as non-portable. import recognises both the claim equality and the exists join and maps them back to memberOf when the table matches a configured mapping, otherwise to the older in(...) membership form. Expired memberships (expiresAt) compile to and (m.expires_at is null or m.expires_at > now()) when the mapping names an expiresAt column.
The same memberOf tree is what permdock/drizzle, permdock/prisma and permdock/kysely compile at query time: active-tenant equality, in over held tenant or team ids, declared-parent or, or an exists join when options.memberships names the table. Prisma has no EXISTS in WhereInput, so it always uses the frozen membership ids. permdock rls generate emits the SQL twin of that subset.
Subject attributes (ABAC)
Attribute conditions compare a row column with a claim: where: { region: principal.claims.attrs.region, clearance: { lte: principal.claims.attrs.clearance } }. generate compiles them as follows:
- Nested paths.
principal.claims.attrs.region(orprincipal.claim.…) walks each segment with->and reads the last with->>. Every segment must match^[A-Za-z_][A-Za-z0-9_]*$and is checked against the prototype-key blocklist (__proto__,constructor,prototype); a path has at most eight segments. In thegucdialect the first segment is the setting (app.attrs, holding JSON text) and the rest is a JSON path. - Typed comparisons. The claim is cast to the column's type, read from the resource's Standard JSON Schema: numbers (
integer,number) compare asnumeric, booleans asboolean, anddate-time,dateanduuidstrings astimestamptz,dateanduuid. A number or boolean claim must be that JSON kind; a string"3"against a numeric column isnull, so the row is filtered, ascandenies it. Other columns, and resources whose validator has no JSON Schema, compare as text. - Array claims.
inandnotInagainst a claim build a Postgres array from the claim's elements of the column's kind witharray(select ...), which is uncorrelated, so Postgres evaluates it once per statement (an InitPlan). A missing or non-array claim is the empty array.notInkeeps anullcolumn out, as the in-memory evaluator does. - Request context. A ref to
context.*is not in the token, so no policy can read it.generaterefuses the grant with a message namingpermdock doctorPD027, or skips it under--skip-closures; the check stays in the application.
Only server-set claims belong in these conditions. On Supabase that is a claim the custom access token hook adds or app_metadata, never user_metadata, which the user can edit. tests/integration/src/rls-abac.test.ts runs a region and clearance matrix through rlsParity on the supabase, neon and guc dialects, against can and the snapshot client.
Anything else (now() arithmetic, CASE, multi-join subqueries, unmapped custom functions) becomes opaque({ sql, fingerprint }): kept verbatim for regeneration, flagged in the catalog, unusable for app-side can. Named helpers listed in rls.functions become sqlFunction instead.
Closure table
Graph grants (relationships) compile against a closure table instead of a recursive query per row. When a policy has through: 'parent' grants, generate emits:
<schema>.permitted_<resource>_ids(p_relation text)for every resource a graph grant reads, with<resource>in snake_case (chatThreadbecomespermitted_chat_thread_ids): asecurity definerhelper that returns, as text, the ids of that resource the subject holdsp_relationon (all graph relations when it isnull). An edge relation reads its table (object,subjectand, when named,expiresAt > now()); a field or principal relation reads the resource's own table, with the principal relation'speriodagainstnow(). It is keyed by relation, not by permission key, so a grant onviewernever matches aneditoredge.<schema>.permdock_closure(resource, ancestor, descendant, depth), one row per ancestor a row reaches within the deepestdepthany grant walks for that resource, including the row itself at depth0. The walk from a row stops after a restricted row whoserestrictedcloses'parent', so an ancestor above such a row never appears for anything at or below it. A self-parented resource whoserestrictedcloses a link a grant crosses keeps 32 levels here. RLS is enabled on it, andauthenticatedreads only rows whose ancestor its ownpermitted_<resource>_ids(null)returns, so the table does not expose other tenants' trees.<schema>.permdock_restricted_<resource>()for a self-parented resource whoserestrictedcloses a link a grant crosses: asecurity definerhelper that returns, as text, the ids of the rows that are restricted, sit below a restricted row within 31 levels, or have a chain longer than that. A grant across the closed link adds"parentId" is null or "parentId"::text not in (select <schema>.permdock_restricted_<resource>())next to the row's ownrestricted is not true, so a restricted row hides its subtree from the link ascandoes, and a row the same statement inserts is checked through its parent.- Per self-parented table, four statement triggers (
after insert,after update,after deletewith transition tables,after truncate) that call<schema>.permdock_closure_<resource>(ids). It refuses a write that makes a row its own ancestor (check_violation), recomputes the closure for the changed rows and their descendants within the depth, and runs once over the whole table when the migration is applied.
A graph grant then compiles to the subquery in the table above. The helper sits in array(select ...) so Postgres runs it once per statement as an InitPlan; the outer in (select descendant ...) is a hashed subplan, also once per statement. Row ids are compared in the column's type from the resource schema (descendant::uuid); a column with no schema type is compared as text. --force on a table whose field or principal relations the helper reads from that same table is reported, since the helper would recurse through the table's own policy.
permdock rls verify --tree --db <url> checks parity on a generated graph instead of fixtures: for each walked resource it seeds two chains one level past the depth, side branches that alternate restricted, child rows of every resource that parents to it and edges or relation columns for a few generated principals, then compares can() with what each principal selects under RLS, and rolls everything back. Required columns the generator does not set, such as a tenant foreign key, take an existing referenced value, a value the column's enum type or check constraint admits, a value from rls.treeValues, or a placeholder of their type, distinct per row in a column that a unique index covers, directly or through an index expression. Connect as a role that owns the tables (or bypasses RLS) and is a member of authenticated. The output counts checks and granted checks, so a tree nobody reaches cannot pass by agreeing. tests/integration/src/rls-verify-tree.test.ts runs it over the shared SaaS folder tree; tests/integration/src/rls-graph.test.ts runs it, and a fixed tree through moves, restriction changes and a refused cycle, on Postgres.
Field security
fields and pick redact columns in the application; the policies above decide rows. --fields views (rls.fields: 'views') carries the field decision into the database as one view per table whose read grants limit fields:
create or replace view "invoice_visible" with (security_invoker = true) as
select
"id",
case when ("orgId" in (select "permdock".permitted_tenant_ids('invoice.read#1')))
or ("orgId" in (select "permdock".permitted_tenant_ids('invoice.read#2')))
or (("orgId" in (select "permdock".permitted_tenant_ids('invoice.read#4')))
and ("authorId" = (select "permdock".permdock_user_id())))
then "amount" end as "amount",
case when (...) and not (select "permdock".permdock_has('invoice.read#5'))
then "note" end as "note",
"title"
from "invoice";- Rows come from the table's own policies, because the view is
security_invoker: a row the caller cannot select is not in the view either. With--helpers-onlythose policies are hand-written and the views are still generated, one mask per column over the same grants, so a customer-scope role withfieldsand a staff role without them read the same view with different columns. - Columns are restricted when a read allow's
fieldsleaves them out or a read deny'sfieldslists them. A restricted column iscase when <permitted> then col end, where<permitted>iscan(permission, row, { field }): covering allows ORed, covering denies negated. The row key and every other column pass through unchanged. - Grant keys split by field set as well as by condition, so each helper call names the roles that reach a column: above,
invoice.read#2is finance with['id', 'orgId', 'title', 'amount']andinvoice.read#5is the auditor's deny onnote. - Field-only grants (a read deny with
fields, an allow with an empty list) leave the row policy, because in the application they never decide the row; they live in the masks. - Cost. Each mask term is the row policy's own uncorrelated helper call, so the helpers still run once per statement, never once per row.
A view alone protects nothing while the table returns the columns to anyone who selects it, so the command warns and doctor PD030 flags it. --revoke-columns (rls.revokeColumns: true) closes the table: anon and authenticated keep select on only the unrestricted columns and the key. A security_invoker view cannot read a column its caller has lost, so the restricted columns come from a companion, <table>_visible_fields, which runs with its owner's rights. The companion lives in the helper schema (rls.schema, permdock by default), which the Data API does not serve, so Supabase's security_definer_view lint (0010) does not flag it; the view's roles get usage on that schema:
create or replace view "permdock"."invoice_visible_fields" with (security_barrier = true) as
select "id" as "permdock_key", case when (...) then "amount" end as "amount", ...
from "public"."invoice"
where (...amount mask...) or (...note mask...);
create or replace view "public"."invoice_visible" with (security_invoker = true) as
select t."id", f."amount", f."note", t."title"
from "public"."invoice" t
left join "permdock"."invoice_visible_fields" f on f."permdock_key" = t."id";The companion masks every column with the full field decision, denies included, and keeps only rows where some restricted column is readable. Reading it directly therefore shows nothing pick would hide: a readable column implies a readable row. security_barrier stops a caller's predicate from running before the masks. The companion's owner must see the rows: the table owner without FORCE ROW LEVEL SECURITY, or a BYPASSRLS role; otherwise every restricted column reads as null, which fails closed. Writes are unchanged: insert, update and delete keep their table-level grants and policies, but returning * and select * on the table now fail with 42501.
Parity is checked end to end. rls verify (with rls.fields: 'views') and rlsParity({ fieldViews: true }) read each fixture's row back from <table>_visible and compare the columns holding a value with those pick keeps, plus the key. rls import reads the views back as a fieldViews export of per-column grants. tests/integration/src/rls-fields.test.ts runs the matrix on Postgres for the supabase, neon and guc dialects, with and without --revoke-columns, including anon reads through the view.
Request lifecycle
generate:
-
Load
definePolicyoutput; collect role and top-level grants and group them by resource table and action. -
Assign grant keys (one per permission and condition group) and seed
role_permissions; emit the helpers. -
Flatten allows and denies per (table, command, audience) into one permissive and one restrictive policy (
--policy-per-rolekeeps one per role and permission). -
Compile each condition with the dialect and write the target:
sql: one idempotent migration (helpers,drop policy if exists+create policy, grants,enable row level security).drizzle:pgPolicy(...).link(schema.<table>)exports, withauthenticatedRoleandauthUidfromdrizzle-orm/supabaseunder thesupabasedialect andpgRole(...).existing()otherwise (Drizzle).prisma: Prisma 8policy_*blocks and a header naming the models that need@@rls. A deny grant stops generation, because Prisma 8 policies are permissive only (Prisma).
For
drizzleandprisma, everything a policy object cannot express (helpers, the closure table, grants, column privileges,enableandforce row level security, field views) goes to<out>.migration.sqlbeside the output.--checkcompares both files. -
With
--rbac supabase, also emit theapp_role/app_permissionenums,user_roles, and anauthorize()function over the samerole_permissionsfor hand-written policies (Supabase provider). The token hook comes frompermdock supabase hook generate.
import:
- Read
pg_policies(schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check) andpg_class.relrowsecurity, or parse a SQL dump. - Parse
qualandwith_checkwithpgsql-parser(libpg_query in WASM) by wrapping each asselect 1 where <expr>and taking thewhereClause. - Pattern-match to portable nodes (recognising both
IN (select ...)andEXISTS (select 1 ...)membership forms, andFuncCallnames listed inrls.functionsassqlFunction); splitFOR ALLinto four entries; maproles = {public}to all subjects. - Fingerprint by the deparsed AST, not the source text, because
pg_get_exprnormalises casts and parentheses. Unmapped function calls stayopaqueand printadd rls.functions.<name>. - Write
src/permissions.generated.ts: a deterministicdefinePermissions()with a// @generatedheader, resource schemas for the chosen validator or referencing Drizzle schemas viadrizzle-zod, and a catalog oftable, cmd, permissive, roles, condition | opaque, fingerprint, sourceSql. Import writes definitions only; grants stay with the author indefinePolicy.
verify:
- For each fixture
{ subject, row, newRow?, action }compute the in-process outcome withcan. A fixture subject may carryroles,membershipsandtenant; the runner puts them in the claims (or GUCs), which is whatjwt-mode helpers read. Indatabasemode the helpers readuser_rolesand the membership table, so the database under test holds the same memberships as the fixtures. - In a
BEGIN ... ROLLBACKtransaction runset local role,set_config('request.jwt.claims', ..., true)withsuband the tenant claim (or the GUC equivalents), execute the statement withRETURNING, and classify:allowed(rows returned),filtered(zero rows),rejected(SQLSTATE42501). - Compare:
grantedmust beallowed;deniedmust befiltered(forUSING) orrejected(forWITH CHECKand missing grants).sqlFunctiongrants comparecan()on the twin with the database executing the real function. Emit as pgTAP files forsupabase test dbor run directly from Node with the optionalpgpeer (--db).
The preamble in step 2 is the one PostgREST runs and the one withPostgresClient from @supabase/server/middleware/postgres runs per transaction on a direct connection: set_config('request.jwt.claims', <ctx.jwtClaims>, true) then set local role to the token's role, which it accepts only as authenticated or anon and refuses for anything else, service_role included. A policy that passes permdock rls verify therefore behaves the same under ctx.postgres, and a toWhere filter can ride the same client (ctx.postgres.query with the compiled predicate) when the app wants the filter in the query as well as in the policy. withPostgresAdminClient is the bypass; it belongs behind an explicit permdock.assert (Supabase provider).
What it validates
generate: everyupdate/deletegrant has SELECT coverage; no table ends with zero permissive policies for a role and command it grants; one permissive policy per table and command, so Splinter'smultiple_permissive_policieshas nothing to report; role checks run once per statement, soauth_rls_initplanhas nothing to report either; noservice_rolepolicies; non-portable grants are reported with their role and grant so the author can chooseopaqueor a rewrite.import: fingerprints of previously imported policies; a changed fingerprint is reported as drift rather than silently overwritten; Splinter-style findings (auth_rls_initplan,multiple_permissive_policies,rls_enabled_no_policy) are surfaced when the database exposes them.verify: mismatches fail the run; opaque policies are reported as untestable app-side;sqlFunctiongrants are reported as verified through twin; SQL three-valued logic risks (auth.uid()NULL, nullable columns) are called out when a fixture row has nulls in filtered columns.
How denials surface
In the database, a denial is one of three observable outcomes and the runner names them the same way:
| Outcome | Cause | Client sees |
|---|---|---|
filtered | USING policy false | zero rows, no error |
rejected | WITH CHECK false, or missing GRANT | SQLSTATE 42501 |
allowed | policy true and grant present | rows returned |
The in-process side answers with a Decision, so a denied decision paired with filtered or rejected is parity, and granted paired with anything else is a failure. HTTP adapters convert 42501 from the database into the same RFC 9457 403 body as an in-process denial when the app opts in.
Databases without row-level security
permdock rls is Postgres-only because CREATE POLICY is. SQLite and its hosted forms (Cloudflare D1, Turso and libSQL, Expo SQLite and op-sqlite on device), MySQL and PlanetScale, and document stores have no RLS to generate or verify. The rest of the data story still applies to them: permdock.where compiles the same portable condition to Drizzle, Kysely or Prisma where clauses for those dialects, and filter runs in memory. The parity suite runs on Postgres and PGlite only; ormParity in permdock/testing takes a run callback that queries any database, so an app on SQLite can run it against its own schema. What these targets lose is the second, database-enforced line of defence; the threat model lists RLS as defence in depth, not as the primary control, so a D1 or Turso deployment is complete without it. On-device SQLite in React Native additionally uses the client snapshot for filter; the where compiler is the same one the server uses (React Native). MongoDB and local-first sync engines with their own permission languages are not where targets (ecosystem index).
Example app
apps/examples/supabase-rls: a Hono server on 127.0.0.1:3469 that boots without a database. GET /rls/authorize checks that generated SQL never contains service_role; PATCH /posts/:id uses subjectFromSupabase with fixed claims.
Why
- Generated SQL keeps the evaluator's semantics, wildcards included. A
containsvalue that reachesLIKEunescaped turns%and_into wildcards, so the database admits rowscanrefuses. Escaping in SQL rather than in the value covers claim references too, where the value is only known at query time. FORCE ROW LEVEL SECURITYis a flag. Without it the table owner bypasses every policy, which is the usual path for migrations, seed scripts and the Supabase dashboard. Forcing it by default would break those on the first run.--force(orrls.force) emits it for teams whose application connects as the owner. The Drizzle and Prisma targets write it to their.migration.sqlfile.- Drizzle and Prisma get a policy file and a migration file. drizzle-kit and Prisma 8 diff policies, so the policies belong in their schema where the migration tools see them. Neither models grants,
security definerfunctions or triggers, and leaving those out would leave RLS enabled with no grant (every query42501) or policies calling helpers that do not exist. One generated SQL file beside the policies keeps both halves from the same run, and--checkfails when either drifts. - Drizzle policies use
.link, not the table callback. Writing into thepgTablecall would mean editing the user's schema file.pgPolicy(...).link(table)attaches the policy from a separate module, so the generated file can be overwritten on every run. - Hand-written views are a
doctorwarning, not generated SQL. Views over application data are application SQL, and some are meant to read past RLS (a public aggregate), so doctor PD022 names each view withoutsecurity_invokerand leaves the choice explicit (doctor). The only viewsgeneratewrites are the opt-in field views, and it writes themsecurity_invoker. - Field security is a per-row view, not Postgres column privileges. RLS stayed row-level until
--fields views, and it still is by default. Column privileges belong to a database role, not to a row or a tenant, sogrant select (amount)cannot say that finance readsamountonly in its own organization, and a Postgres role per app role and tenant is the design the claims-not-roles rule below rejects. A view with acaseper restricted column evaluates the same field decision aspickfor each row, from the same helper calls as the row policy, so it costs one helper call per statement and stays in step with the application. A permission per field (invoice.read.amount) would multiply keys and policies for what is one grant option. --revoke-columnsis opt-in and uses an owner-rights companion. Closing the columns breaksselect *andreturning *on the table, which existing code relies on, so it is a second flag. Column privileges are the only way to close a column, and asecurity_invokerview cannot read a column its caller lost, so the restricted columns go through<table>_visible_fields. The companion is safe to read directly because it masks each column with the whole field decision and keeps only rows with a readable column. The rows of<table>_visiblestill come from the caller's own RLS. PD022 exempts the companion by itspermdock:field-companioncomment.- The row key always passes through. The view is filtered and joined on it, and a client needs it to address the row it can read. A read grant whose
fieldsomit the key still sees it in the database; the command warns, and parity checks count the key as kept. REVOKE/GRANTis always emitted, with no--grantsflag. Thesqltarget revokes table privileges fromanonandauthenticatedand grants back only the commands the policy uses. A policy without the matching grant fails with42501, and a grant without a policy is the open-by-default mistake RLS exists to prevent. Making the pair optional would allow either half to go missing.- The
neondialect is pinned toauth.user_id(). That is the helperpg_session_jwtinstalls, and the generated SQL calls it through(select ...)so Postgres evaluates it once per statement. Claims go throughauth.session(). A different helper name is agucdialect with--guc-prefix. - App roles are claims, not Postgres roles. Every generated policy targets
authenticated. Roles, tenants and memberships reach the policy as JWT claims or GUCs, or through the membership table formemberOf. PermDock never generates a Postgres role per tenant or per app role: the number of Postgres roles would grow with the customer count,GRANTchanges would need DDL at request time, and connection pools hold one role per connection. A deployment that already uses per-tenant Postgres roles makes each one a member ofauthenticated(grant authenticated to tenant_a), so the generated policies apply to it, and the policies still read the tenant from the claim. - Role checks are helper functions, not inline joins. An inline
existsover the membership table per role, orauthorize(perm, "org_id"), repeats the role lookup for every row; onesecurity definerhelper per scope answers it once per statement and keeps the grant-to-role mapping in one seeded table thatimportcan read back. Collapsing to one policy per table and command keeps the plan to one filter, and--policy-per-roleremains for teams that review policies per role. - Attribute claims are typed by the resource schema, not by a flag. A claim is text after
->>, and comparing text with a numeric or date column is a type error at query time, or worse, a text comparison where'10' < '9'. The resource schema already says what each column is, so the generator reads it instead of asking for a per-column flag. Numbers and booleans also check the claim's JSON kind: a cast would turn the string"3"into3and grant a row the in-memory evaluator denies, and parity betweencanand the database is the point of generating the policy. Array claims usearray(select ...)rather than a row-by-rowjsonb_array_elementsso the claim is expanded once per statement, like the role helpers. - Casts go on the parameter, never on the column.
((select auth.jwt()) ->> 'tenant_id')istext; comparing it to auuidcolumn either fails or casts the column, which cannot use its index.--tenant-type(defaultuuid) casts the claim instead. The helpers, the token hook and the ownership triggers compare a membership's user and scope columns uncast too: a PL/pgSQL variable declared<table>.<column>%typetakes the value, and a statement parameter leaves Postgres to infer it from the column;tests/integration/bench/param-casts.test.tschecks the plan uses the index. The closure and link helpers over graph resources still taketext[], because a graph resource declares no id type and an array of%typeneeds Postgres 17. - Graph grants read a trigger-maintained closure, not a recursive query. A recursive CTE in a policy runs per row; the closure turns reachability into an indexed lookup, and statement triggers keep it current in the same transaction as the write, so a moved folder is visible or hidden as soon as it commits. The triggers stop at the same restricted rows and depth as
can, and refuse cycles, so the database and the evaluator agree by construction andrls verify --treechecks it. ancestor = any (array(select ...)), notancestor in (select ...). Postgres pulls a plaininsubquery into a semi join, and with a set-returning helper on the inner side it can rescan the helper for every closure row. The array form is always an InitPlan, run once per statement.rls migraterewrites only what it can prove equivalent. A project moving off its own helpers usually has hundreds of calls in policies, and rewriting them by hand is where a key gets mistyped. The safe subset is narrow: a plain column, a literal key that maps to a declared permission, seeded on the helper's scope and free of row conditions. Anything else is reported with its line and left as written, because a guessed rewrite of a computed id or a conditioned key would silently widen or narrow access. Calls inside function bodies are never touched: those functions run with their own privileges and are often the legacy helpers themselves. Splicing by parser location rather than deparsing keeps the reviewer's diff to the calls that changed.--helpers-onlyexists for hand-written policies. Some tables keep policies that a grant cannot express, and a team adopting PermDock table by table should not have to choose between generated policies everywhere and none. With only the helpers generated,verify --introspectcannot compare policies, so it checks what still can go wrong: the seeds exactly, and every key a hand-written policy passes.rls importproposes, never assumes, a membership table. AnEXISTSjoin over a table thatrls.membershipsdoes not name staysopaque, and the command prints a commented stanza to paste. Guessing that a table holds memberships would turn an unknown join into a grant.
Related standards
- Postgres RLS:
CREATE POLICYsemantics, permissive versus restrictive,pg_policies, the portable subset, parsers and testing. - CLI: rls: command reference.
- Drizzle, Prisma, Kysely, Supabase.
- Tenants, teams and scoped roles: the
memberOfnode and its portable compilation.
Last updated on
Kysely
permdock/kysely compiles portable conditions into Kysely expression-builder callbacks with toWhere, fails closed when nothing is granted, and documents the Kysera @kysera/rls dual-mode prior art.
Supabase
The permdock/supabase provider maps Supabase JWT claims to a PermDock subject, scaffolds the authorize() RBAC tables and hook, and pairs them with the per-statement RLS helpers (permdock_has, permitted_<scope>_ids and member_<scope>_ids per named scope) that generated policies call.