PermDock
Getting started

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.

StepWhat changesWhat keeps working
1. Model the vocabularydefinePermissions with the app's own keys, names and verbsEverything; nothing is generated yet
2. Helpers beside SQLrls generate --helpers-only --shimsEvery hand-written policy, function, view and trigger
3. Rewrite policiesrls migrate --writeFunction bodies, views and triggers, through shims
4. Token cutoverThe Supabase token hook, claimsFirst, legacy claimsTokens issued before the deploy
5. Custom-role datarls generate --backfill-out copies the app's role tablesRoles stored under old keys; the app's own tables
6. Rename keysrenamed aliases, then a deprecation window measured by doctorOld 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 view or archive is fine. rls.actions says which SQL command each verb compiles to, and 'none' keeps a verb out of row policies while permdock_has still answers for it (action verbs).
  • Colliding resource names. A platform billing and an organisation billing keep their keys under two groups, and name gives 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 ApprovalPolicySource instead of code (approval policies as data). Two-step sign-off is mode: 'sequential' with stages, and "the expense's manager" is a relation() 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 --write

migrate 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):

ModeReadsA role change appliesTriggers left
databaserole_permissions and the membership tables, at query timeOn the next statementNone for grants
jwtThe memberships and user_role claims the token hook writesWhen the token is reissued; fresh permissions deny with stale-credentials before thatpermdock_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.

PolicyMedian execution time
Per-row has_permission over user_permissions980 ms
permitted_tenant_ids, database mode5.8 ms
permitted_tenant_ids, jwt mode6.4 ms

To retire the table:

  1. Generate the helpers beside it with --shims and an rls.migrate.helpers entry for has_permission (shims). The shim answers for function bodies and views that still call the old name.
  2. Add user_permissions to rls.migrate.tables and run rls migrate --write to rewrite the policies. The report lists the refresh triggers and every view, function or policy that still reads the table; permdock doctor reports the same as PD066.
  3. 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, without cascade (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.claims keeps 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" } },
);
  1. Rename the leaf in code and add the old key to renamed. Code and new tokens use customer.read; stored roles, OAuth scopes, AuthZEN actions, hosted grants and findPermission still accept organization.customers.view (renamed keys). rls generate seeds role_permissions under both keys, so SQL that passes the old key keeps its access. permdock diff reports the change as renamed, which is not breaking.
  2. Wait out the deprecation window. permdock doctor PD055 prints the update statements 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.
  3. Remove the alias once both checks are quiet and one jwt_expiry has passed. permdock diff reports alias-removed as 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 renamed changes nothing for anyone; dropping it denies whatever still sends the old key. Classifying alias-removed as breaking puts the decision where the risk is.

Last updated on

On this page