rls
Generate Postgres row-level security policies from a PermDock policy, import existing policies into a generated definition, and verify that both agree.
Status: planned Phase: 3
permdock rls is the round-trip between PermDock's policy and Postgres row-level security. generate compiles roles and grants to policies, import reads pg_policies back into a definePermissions() module, and verify proves that can() in-process and the database agree. The runtime side (permdock.where() and the toWhere compilers) is documented on the RLS adapter page; the semantics and the portable subset come from the Postgres RLS research.
generate
permdock rls generate --target drizzle --dialect supabase --out src/db/policies.ts
permdock rls generate --target sql --dialect neon --out migrations/0007_rls.sql
permdock rls generate --target prisma --dialect guc --out prisma/policies.prisma
permdock rls generate --target sql --dialect supabase --rbac-scaffold # also emit Supabase's authorize() RBAC tablesInputs: the policy module from permdock.config.ts (roles as data) and a mapping from resources to tables. The mapping is read from --from drizzle (table names and columns from the Drizzle schema, resource schemas via drizzle-zod) or declared in the config as rls.tables.
Targets:
--target | Output |
|---|---|
drizzle | pgPolicy() calls next to the table definitions, using drizzle-orm/supabase helpers (authenticatedRole, authUid) when --dialect supabase |
sql | ALTER TABLE ... ENABLE ROW LEVEL SECURITY plus CREATE POLICY statements, one file, idempotent |
prisma | Prisma 8 native policy_* blocks in the schema |
Dialects decide how the subject reaches SQL:
--dialect | subject.id | claims / tenant |
|---|---|---|
supabase | (select auth.uid()) | auth.jwt() ->> 'claim' |
neon | (select auth.user_id()) | JWT claims via pg_session_jwt |
guc | current_setting('app.user_id', true) | current_setting('app.<claim>', true); the prefix is configurable with --guc-prefix |
Semantics mirrored from the research: read becomes FOR SELECT USING, create becomes FOR INSERT WITH CHECK, update becomes FOR UPDATE USING (where) WITH CHECK (check), delete becomes FOR DELETE USING; allow grants are PERMISSIVE, deny grants are RESTRICTIVE with NOT (condition); a SELECT policy is emitted whenever UPDATE or DELETE is, because Postgres needs it; the default target role is authenticated; service_role is never emitted.
Only portable conditions compile (0010): equality against the subject, in / notIn membership (emitted as IN (select ...)), literal columns, and / or / not. A closure grant fails generation with the grant's location and the message that the grant must be rewritten as a portable condition or excluded with --skip-closures.
With --rbac-scaffold and --dialect supabase, the output also contains Supabase's recommended user_roles, role_permissions and authorize('permission') function, and policies call authorize('post.delete') instead of inlining role checks, so the named-permission catalog exists in the database too.
import
permdock rls import --db $DATABASE_URL --out src/permissions.generated.ts
permdock rls import --db $DATABASE_URL --out src/permissions.generated.ts --schema valibot
permdock rls import --db $DATABASE_URL --out src/permissions.generated.ts --from drizzleimport reads pg_policies, parses each USING and WITH CHECK expression with pgsql-parser (libpg_query compiled to WASM) and pattern-matches the AST against the portable subset, recognising the EXISTS form of membership and the Supabase auth.uid() / auth.jwt() idioms. Recognised expressions become portable conditions; everything else becomes opaque({ sql, fingerprint }), where fingerprint is a hash of the deparsed AST so formatting changes do not register as drift.
The output is a deterministic definePermissions() module with a // @generated header: one resource per table with its actions derived from the policy commands, resource schemas emitted for the validator chosen with --schema zod|valibot|arktype from the table's columns, or referencing existing Drizzle tables through drizzle-zod with --from drizzle. Conditions are emitted as a role fragment alongside, so the generated file can be merged with hand-written definitions and policy fragments (Larger apps).
No other tool imports RLS into application permissions; Kysera's @kysera/rls is the only dual-mode prior art and does not read from the database.
verify
permdock rls verify --db $DATABASE_URL
permdock rls verify --db $DATABASE_URL --subjects fixtures/subjects.json --emit pgtap > tests/rls.sqlverify runs a parity test for every (subject, permission, row) triple in the fixtures: can() in-process against the PermDock policy, then the same operation in the database under set local role authenticated with the subject's claims in request.jwt.claims (Supabase) or the GUCs (guc). Outcomes per row are allowed, filtered (the row disappears from a SELECT) or rejected (error 42501). Disagreements are listed with the grant and the policy name; the exit code is 1.
Tests can be emitted as pgTAP (--emit pgtap) for teams that run database tests in CI, or executed directly from Node using the connection string. @permdock/testing exposes the same runner for Vitest.
CI
- run: pnpm exec permdock rls generate --target sql --dialect supabase --check # generated migration up to date
- run: pnpm exec permdock rls verify --db $DATABASE_URL # in the job with a Postgres servicetests/integration in this repository runs verify against Postgres in testcontainers.
Open questions
- Whether
generateshould emitREVOKE/GRANTstatements for table privileges alongside policies. The research recommends it because a missing grant raises the same42501as aWITH CHECKfailure and masquerades as a policy denial; leaning yes, behind--grants. - How
approval: 'human'grants should compile: skipped with a warning (RLS has no approval concept) is the current plan.
Related
openapi
Emit security and securitySchemes into an existing OpenAPI document (or as an Overlay) from the permission catalog, or import a document into a generated definition.
doctor
Diagnose a PermDock installation: server-only imports in client entries, unknown references, ungranted permissions, stale catalogs, roles or tenants read from unverified claims, missing skills and the TypeScript version.