Existing apps
Adopt PermDock in an app that already has permission keys, SQL helpers, tokens and stored custom roles, then move to its conventions one step at a time without breaking signed-in users.
An app with its own authorization adopts PermDock in steps. Each step leaves the app working: legacy SQL keeps answering, tokens already issued keep their access, stored custom roles keep resolving, and old permission keys keep working until permdock doctor shows nothing uses them.
| Step | What changes | What keeps working |
|---|---|---|
| 1. Model the vocabulary | definePermissions with the app's own keys, names and verbs | Everything; nothing is generated yet |
| 2. Helpers beside SQL | rls generate --helpers-only --shims | Every hand-written policy, function, view and trigger |
| 3. Rewrite policies | rls migrate --write | Function bodies, views and triggers, through shims |
| 4. Token cutover | The Supabase token hook, claimsFirst, legacy claims | Tokens issued before the deploy |
| 5. Custom-role data | rls generate --backfill-out copies the app's role tables | Roles stored under old keys; the app's own tables |
| 6. Rename keys | renamed aliases, then a deprecation window measured by doctor | Old keys in SQL, tokens and stored roles |
Model the existing vocabulary
Keep the keys the app already uses. A key renamed on day one is a migration for every stored role and every SQL call; step 6 does it later, with aliases.
- Verbs. An action named
vieworarchiveis fine.rls.actionssays which SQL command each verb compiles to, and'none'keeps a verb out of row policies whilepermdock_hasstill answers for it (action verbs). - Colliding resource names. A platform
billingand an organisationbillingkeep their keys under two groups, andnamegives one of them a distinct resource name for tables, relations and AuthZEN (resource names). - Application metadata. Risk levels, undo windows and UI hints go in
meta.x, which PermDock carries to the catalog and snapshots and never reads. - Platform roles. Operator roles such as support tiers are declared global roles. Tiers an operator defines at runtime are platform custom roles, capped by the global roles marked
assignable. - Approval rules kept as data. Thresholds an organisation configures, such as "payments over 1000 need finance", come from an
ApprovalPolicySourceinstead of code (approval policies as data). Two-step sign-off ismode: 'sequential'withstages, and "the expense's manager" is arelation()approver (approval security).
permdock rls import reads the existing policies into a generated definePermissions module when the database is the better starting point (import).
Generate helpers beside the existing SQL
permdock rls generate --target sql --rbac supabase --helpers-only --shims \
--out supabase/migrations/0056_permdock.sql--helpers-only writes the helper functions, role_permissions and its seeds, and no policies, so the app's hand-written policies stay in charge (helpers only). --shims adds a wrapper under each legacy helper name listed in rls.migrate.helpers, which maps the old key and calls the generated helper (shims). From this deploy on, legacy SQL answers from PermDock's grants.
Rewrite policies
permdock rls migrate --rbac supabase --sql supabase # dry run
permdock rls migrate --rbac supabase --sql supabase --writemigrate rewrites calls to the legacy helpers inside create policy and alter policy onto the generated helpers, and maps keys through rls.migrate.keys, then renamed, then rls.migrate.prefixes (migrate). It lists every call it skipped: function bodies, views and triggers stay on the shims until they are rewritten by hand. permdock doctor PD056 counts the remaining callers of each legacy name; drop a shim and its legacy function once its count is zero.
Retire trigger-maintained permission tables
Many Supabase apps keep a user_permissions (user_id, organization_id, permission) table that triggers on the membership and role tables keep in step, and read it through a security definer function in each policy:
create policy "post_select" on public.post for select to authenticated
using (public.has_permission("orgId", 'post.read'));The call takes a row column, so Postgres runs the function once per row, and a security definer function is never inlined. The table is a second copy of the grants: an edit to which permissions a role carries has to re-sync every holder, and a missed trigger path leaves rows that grant too much or too little.
The generated helpers read the grants where they live and return the permitted ids once per statement. rls migrate rewrites the call above (the row form in rls.migrate.helpers) to:
using ("orgId" in (select permdock.permitted_organization_ids('post.read')))Where the helpers read roles and memberships is the --authorize mode (modes):
| Mode | Reads | A role change applies | Triggers left |
|---|---|---|---|
database | role_permissions and the membership tables, at query time | On the next statement | None for grants |
jwt | The memberships and user_role claims the token hook writes | When the token is reissued; fresh permissions deny with stale-credentials before that | permdock_bump_authz_version on each source table, which bumps authz_ver instead of copying grants (authorization version) |
Pick database when a revoked role must stop working on the next request for every permission. Pick jwt when membership lookups must stay off the query path, and list the sensitive permissions in fresh so they check authz_ver.
tests/integration/bench/definer-helper.test.ts measures the three shapes on one table: 100,000 post rows over 20 tenants, a member who reads one tenant's 5,000 rows, the median EXPLAIN ANALYZE execution time of 9 runs, on Postgres 16.15 in Docker on an Apple M5 Max.
| Policy | Median execution time |
|---|---|
Per-row has_permission over user_permissions | 980 ms |
permitted_tenant_ids, database mode | 5.8 ms |
permitted_tenant_ids, jwt mode | 6.4 ms |
To retire the table:
- Generate the helpers beside it with
--shimsand anrls.migrate.helpersentry forhas_permission(shims). The shim answers for function bodies and views that still call the old name. - Add
user_permissionstorls.migrate.tablesand runrls migrate --writeto rewrite the policies. The report lists the refresh triggers and every view, function or policy that still reads the table;permdock doctorreports the same as PD066. - Rewrite the remaining readers by hand. When the report shows none, run
rls migrate --retire-out supabase/migrations/: it writes one migration that drops the refresh triggers and their functions, the uncalled shim and the table, withoutcascade(retire a materialised table).
Cut tokens over
Tokens issued before the deploy carry the old claims until they expire. Two settings keep them working:
claimsFirst(sources)on the membership source trusts the verified token's memberships and reads the database only when the token says it was truncated (tenancy).supabase.hook.claimskeeps writing a claim legacy code still reads, from a schema-qualified function, next to the claims PermDock owns (claims other packages own). Remove the entry once nothing reads the claim.
Keep the old claims for at least one jwt_expiry after the hook deploys, so every live token has been reissued before the old reader goes.
Move custom-role data
In database mode, name the app's role tables in rls.customRoles.from and the roles table in rls.customRoleWrites.roles, then let generate write the copy:
permdock rls generate --target sql --rbac supabase --out supabase/schemas/permdock.sql \
--backfill-out supabase/migrations/The backfill migration saves each role through permdock_trusted_replace_custom_role_grants, mapping old keys the way rls migrate does, and skips a role that already has rows, so it is safe to apply again (backfill). Platform roles, the rows with no tenant, land with scope = 'global'. An entry outside the ceiling is dropped, never widened, and the migration's warning lists each one so an admin can fix it in the app's tables and apply the copy for that role again.
In jwt mode there are no tables to fill: build the grants claim with customRoleClaim(roles, policy) (custom roles), after running validateCustomRole over every role to see what it drops.
The app's RoleSource reads the same rows. Roles stored under old keys keep resolving through renamed, so the copy does not have to rewrite keys.
Rename keys
export const permissions = definePermissions(
{ customer: resource(Customer, { actions: ["read", "update"] }) },
{ renamed: { "organization.customers.view": "customer.read" } },
);- Rename the leaf in code and add the old key to
renamed. Code and new tokens usecustomer.read; stored roles, OAuth scopes, AuthZEN actions, hosted grants andfindPermissionstill acceptorganization.customers.view(renamed keys).rls generateseedsrole_permissionsunder both keys, so SQL that passes the old key keeps its access.permdock diffreports the change asrenamed, which is not breaking. - Wait out the deprecation window.
permdock doctorPD055 prints theupdatestatements that rewrite stored custom roles still on the old key; PD056 counts SQL that still calls a legacy helper. Tokens carry the old key until they expire. - Remove the alias once both checks are quiet and one
jwt_expiryhas passed.permdock diffreportsalias-removedas breaking, so CI holds the pull request until someone confirms the window is over.
An approval pending when the rename deploys does not resume, because its token binds the old key. The call asks again.
Why
- Coexistence before replacement. A big-bang switch from hand-written SQL and custom tokens to generated policies breaks every signed-in user at once if one key maps wrong. Helpers beside the old SQL, shims under the old names and aliases for old keys let each piece move on its own deploy, and doctor and diff measure when the old path is unused instead of leaving it to a guess.
- Aliases resolve; they never widen. A former key resolves to exactly one current leaf, and decisions, snapshots and audit events name only the current key, so an alias cannot create access the current key lacks and the audit trail has one name per permission.
- Removing an alias is the breaking step. Adding
renamedchanges nothing for anyone; dropping it denies whatever still sends the old key. Classifyingalias-removedas breaking puts the decision where the risk is.
Last updated on