Merge resources
Merge an org-level resource and a per-row resource over one table into one resource, with a renamed key per removed permission, one row helper and one catalog entry.
Two resources that rls.tables maps to one table, such as file for organisation-wide access and fileNode for shared rows, double every key, helper and catalog entry for the same rows. A single resource carries both kinds of grant: a role grant scoped through a memberOf relation reaches every row of the tenant, and relation grants on the same leaf reach single rows. permdock doctor reports each such pair as PD075.
Before and after
export const permissions = definePermissions({
file: resource({
actions: ["read", "update", "delete"],
collection: ["create"],
relations: { org: { field: "organization_id", memberOf: "organization" } },
}),
fileNode: resource({
actions: ["read", "comment", "update"],
parent: { field: "parent_id", resource: "fileNode" },
relations: {
org: { field: "organization_id", memberOf: "organization" },
viewer: {
edge: "file_node_grants",
object: "node_id",
subject: "user_id",
},
},
}),
});export const permissions = definePermissions(
{
file: resource({
actions: ["read", "comment", "update", "delete"],
collection: ["create"],
parent: { field: "parent_id", resource: "file" },
relations: {
org: { field: "organization_id", memberOf: "organization" },
viewer: {
edge: "file_node_grants",
object: "node_id",
subject: "user_id",
},
},
}),
},
{
renamed: {
"fileNode.read": "file.read",
"fileNode.comment": "file.comment",
"fileNode.update": "file.update",
},
},
);The role grant allow(permissions.file.read) on an organisation role still reaches every row of the organisation. The relation grant allow(permissions.file.read, { to: relation(permissions.file, "viewer", { through: "parent" }) }), formerly on fileNode.read, reaches the rows shared with the user. The RLS policy on file_nodes ORs both branches, so one key answers both questions.
Steps
- Move the relations. Copy the per-row resource's relations,
parentandrestrictedonto the surviving resource, then delete the per-row resource. One resource has one table and oneidcolumn; a pair that needs two is two resources by definition, so list its table indoctor.sharedTablesinstead. - Decide each verb. Use the table below. Every removed key gets a
renamedentry that names its survivor. - Rename the keys.
definePermissions(tree, { renamed })keeps stored custom roles, OAuth scopes in their:form, AuthZEN actions and hosted grants resolving (renamed keys).rls generateseedsrole_permissionsunder the former keys too, andpermdock diffreports each one asrenamed, which is not breaking. - Rewrite references. Change
permissions.fileNode.readtopermissions.file.read. The removed leaf no longer exists, so the type checker lists every site;permdock codemodcovers only release renames. - Replace the helpers. With
rls.rowHelpers, replace the per-row resource in the list with the survivor:rowHelpers: ["file"].rls generatethen writes onepermitted_file_rows(p_permission)with onecasearm per permission, each arm ORing the role branch and the relation branches. Replacepermitted_file_node_rowscalls in hand-written SQL withpermitted_file_rows, and drop the old function in a migration:rls generatestops writing it but does not remove it from the database. - Regenerate the catalog.
permdock collectwrites one resource entry with every relation and one set of permissions. Each surviving permission lists the keys it replaced inrenamedFrom; the catalog format is unchanged. - Drop the aliases. One major later, remove the
renamedentries.permdock diffreports each asalias-removed, a breaking change, so the release notes name it.
Decide each verb
A verb either merges into a verb the survivor already has, joins the survivor as a new instance action, or moves to collection when it never touches a row. Merging two verbs means their grants now reach the same rows: a user who held only the per-row key keeps the rows shared with them, and an organisation member who held only the org-level key keeps every row of the organisation.
| Pair | Verb | Decision |
|---|---|---|
file + fileNode | read, update | Merge: fileNode.read to file.read, fileNode.update to file.update |
file + fileNode | comment | Join file as an instance action: fileNode.comment to file.comment |
file + fileNode | delete, create | Keep on file; create stays in collection |
knowledge + knowledgeCollection | read | Merge: knowledgeCollection.read to knowledge.read; move creator and member onto knowledge |
workflow + workflowDefinition | read | Merge: workflowDefinition.read to workflow.read; run, update and delete stay org-level role grants |
chat + thread | read | Merge: thread.read to chat.read; owner and participant join org on chat |
An organisation member who should see only the rows shared with them can no longer get that by holding the per-row key alone. Leave the role grant out for that role, or add a deny with a row condition.
Prove the merge
Generate both versions and compare who sees what: for every user and verb, the merged file.<verb> must reach the union of the split file.<verb> and fileNode.<verb> rows. tests/integration/src/rls-merged-resource.test.ts in the PermDock repository does this against Postgres, and permdock rls verify with fixtures for each kind of user does the same against your database.
Why
- The grantee already says the level. A role grant through
memberOfis organisation-wide and a relation grant is per row, so alevel: "org" | "row"flag on each action would repeat what the grant says and add a concept the evaluator must interpret (decision 0036). - One helper per resource. Generating both helpers from one resource would keep the doubled SQL surface the merge removes.
- The doctor reports, the adopter decides. Two resources whose action sets differ on purpose need a decision per verb, which no check can make.
Last updated on
Extend PermDock
Attach typed app data to permissions, resources, roles, plans, grants and memberships, add request data, declare app obligations, set UI defaults and replace adapter responses, without a plugin system and without changing what PermDock decides.
Next.js Cache Components
How a multi-org SaaS keeps permission UI in the prefetched App Shell with Next.js 16.3 Cache Components, Partial Prefetching and instant navigation, how fresh each layer is after a role or plan change, and how Supabase claims plug in.