Relationships
Object hierarchies (nested folders, sub-teams, reporting lines, account delegates) as relation grants that walk a parent chain, decided in process through a RelationSource and in Postgres through a closure table.
Named scopes are tenancy: an organization, a workspace, a team, each a fixed level with its own memberships. Relationships are the object graph under them: folders inside folders, sub-teams, the manager of an employee, a delegate who acts for an account holder for a while. The graph has no fixed depth, so it is not a scope; a grant reaches an object because the subject holds a relation on it or on one of its ancestors.
Declaring the graph
A resource names its parent, the relations principals hold on it, and optionally a restricted column:
import {
allow,
definePermissions,
definePolicy,
relation,
resource,
} from "permdock";
export const permissions = definePermissions({
doc: resource(Doc, {
actions: ["read", "update"],
parent: { field: "folderId", resource: "folder" },
relations: { owner: "ownerId" },
}),
folder: resource(Folder, {
actions: ["read", "share"],
parent: { field: "parentId", resource: "folder" },
relations: {
editor: { edge: "folder_editors" },
viewer: { edge: "folder_viewers", expiresAt: "expires_at" },
},
restricted: "restricted",
}),
});A resource may parent itself (folder above): that is what makes a chain. Every walk over a chain has a depth, 16 unless the grant sets one, and never more than 32.
| Relation | Declared as | Held when |
|---|---|---|
| Field | owner: 'ownerId' or { field } | the row's field is the principal's id |
| Edge table | { edge, object?, subject?, expiresAt?, match?, groups? } | a row of edge has the object id in object (default <resource>_id) and the principal id in subject (default user_id), expiresAt, when named, is null or in the future, and every match column equals its value |
| Principal | { principal, period? } on a principal resource | the row's principal column is the principal's id and now is inside period (startsAt, expiresAt timestamp columns, null leaves a side open) |
| Implied | { includes: ['editor'] } | the principal holds any relation in includes |
| Scope | { field, memberOf } | tenancy, not the graph: a through grant never walks it |
restricted names a boolean column. A restricted object is reached only by grants on itself, never by relations held on its ancestors or through its links, and nothing below it is reached through it. Grants on the restricted object still reach its descendants.
What a restricted row stops
restricted: { field, stops } closes only the paths stops names: 'parent' for the parent walk, and link names for grants that cross those links. The string form restricted: 'restricted' closes the parent walk and every link.
fileNode: resource(FileNode, {
actions: ["read"],
parent: { field: "parentId", resource: "fileNode" },
links: { drive: { field: "driveId", resource: "fileDrive" } },
relations: { viewer: { edge: "file_node_shares" } },
restricted: { field: "restricted", stops: ["parent"] },
}),With stops: ["parent"], a share on a folder above a restricted node no longer reaches it, while everyone who reads the drive still reads the node and its subtree through inherit(permissions.fileDrive.read, { through: ["drive"] }). With stops: ["drive"] the drive link is closed and the parent walk passes the restricted row, so a share on a folder above it still reaches it.
A closed link stays closed below the restricted row. For a self-parented resource, a grant across a closed link does not reach a row when the row is restricted, when a row above it within 32 parent hops is restricted, or when its chain goes on past 32 hops; the last case denies with relation-depth, so a tree deeper than the check never opens by accident. A grant on the restricted row, or a parent walk from a row at or below it, still reaches its subtree. definePermissions rejects an empty stops, a link the resource does not declare, and 'parent' on a resource without a parent.
One edge table, several relations
match filters an edge table by fixed column values, so one folder_members table with a role column serves every relation on the folder. Values are strings, numbers or booleans.
folder: resource(Folder, {
actions: ['read', 'update'],
parent: { field: 'parentId', resource: 'folder' },
relations: {
editor: { edge: 'folder_members', object: 'folder_id', subject: 'subject_id', match: { role: 'editor' } },
viewer: { edge: 'folder_members', object: 'folder_id', subject: 'subject_id', match: { role: 'viewer' }, includes: ['editor'] },
},
}),includes lists relations on the same resource that imply this one: every editor is a viewer. It can sit beside field, edge or principal, or stand alone as { includes: [...] }, a relation held only through its list. definePermissions rejects an unknown name and a cycle.
Groups
groups lets an edge row name a group instead of a principal. column holds the group's resource name, subject its id, and the row holds for whoever holds resources[<name>] on that group. A null column, or the direct value, names a principal; any other value matches nothing.
const asGroupOrUser = { column: 'kind', resources: { team: 'member' }, direct: 'user' } as const
team: resource(Team, {
relations: { member: { edge: 'team_members', object: 'team_id', subject: 'subject_id', groups: asGroupOrUser } },
}),
folder: resource(Folder, {
relations: { viewer: { edge: 'folder_members', /* ... */ groups: asGroupOrUser } },
}),Some share tables keep each subject kind in its own typed column, such as user_id uuid for a person and team_id bigint for a team, with no column naming the kind. Give such a group its own subject column with { relation, subject }. Without column, a row names the principal in the edge's subject when that column is not null, and names a group when the group's own column is not null; a row with both set names both. With column, the group's id is read from its own column instead of the edge's subject.
drive: resource(Drive, {
relations: {
viewer: {
edge: 'drive_shares',
object: 'drive_id',
subject: 'user_id',
groups: { resources: { team: { relation: 'member', subject: 'team_id' } } },
},
},
}),definePermissions rejects a group without its own subject when groups has no column, and a direct value without a column. RLS compares each group column as text, so the column types of the share table and the group's table need not match.
A team may be a member of another team. Nesting groups of the same resource stops after 16 levels; a group on another resource starts a fresh count. A cycle of groups of the same resource denies with relation-depth. A cycle across resources is rejected by definePermissions. Group resources must be declared in the same definePermissions call.
Links
links names to-one references to other resources, so a grant can cross from a row to a related instance that is not its parent:
doc: resource(Doc, {
actions: ['read', 'review'],
parent: { field: 'folderId', resource: 'folder' },
links: { folder: { field: 'folderId', resource: 'folder' } },
}),
folder: resource(Folder, {
links: { team: { field: 'teamId', resource: 'team' } },
// ...
}),
allow(permissions.doc.review, {
to: relation(permissions.team, 'lead', { through: ['folder', 'team'] }),
})through is 'parent' or a list of link names followed in order, each on the resource the previous one reached. With a list, depth walks the last resource's parent chain from where the links end; without it the grant reads that one instance. Each link counts towards the 32-hop limit. A restricted row stops a link walk when its restricted closes that link, the default (what a restricted row stops).
Granting through the graph
relation(resource, name, options?) keeps its signature. Without options the relation must be declared on the row's own resource and reads only the row. With through: 'parent' the grant follows the row's parent chain upward: to the parent resource when the relation is declared there, then along that resource's self-parent for up to depth hops.
export const policy = definePolicy(permissions, {
grants: [
allow(permissions.doc.read, { to: relation(permissions.doc, "owner") }),
allow(permissions.doc.read, {
to: relation(permissions.folder, "viewer", {
through: "parent",
depth: 16,
}),
}),
allow(permissions.doc.update, {
to: relation(permissions.folder, "editor", {
through: "parent",
depth: 4,
}),
}),
],
subject: (user) => ({ id: user.id }),
});An array in to: is an intersection, as everywhere in PermDock: to: [relation(permissions.doc, 'owner'), relation(permissions.folder, 'viewer', { through: 'parent' })] requires both. Two allow grants, as above, OR together. definePolicy rejects a through grant that cannot reach its resource (the row has no parent there, or a through on a resource that does not parent itself), a through over a memberOf relation, and an edge relation declared on another resource without through.
Reporting lines and delegates are the same two tools:
const people = definePermissions({
employee: resource(Employee, {
actions: ["review"],
parent: { field: "managerId", resource: "employee" },
relations: { manager: { principal: "managerId" } },
}),
account: resource(Account, {
actions: ["act"],
relations: {
delegate: {
principal: "delegateId",
period: { startsAt: "delegateFrom", expiresAt: "delegateUntil" },
},
},
}),
});
allow(people.employee.review, {
to: relation(people.employee, "manager", { through: "parent", depth: 8 }),
});
allow(people.account.act, { to: relation(people.account, "delegate") });The manager of an employee holds manager on it; walking the chain, the manager's manager holds manager on the manager, so skip-level managers reach the employee too. The delegate holds delegate only inside the period.
A ceiling on a relationship
A share often counts only while the person also holds a baseline permission in the organization: an external collaborator removed from the organization should lose every share inside it. Add requires to the graph grant:
allow(permissions.drive.read, {
to: relation(permissions.drive, "viewer"),
requires: permissions.file.read,
});The share then counts only on drives whose organization is one where the subject holds file.read through a role, declared or custom, with no deny of it there (requires).
Inheriting a permission through a link
A file node is often readable wherever its drive is, whoever made the drive readable: a role in the organization, a share, a group. inherit(permission, { through }) grants on the row when the subject holds permission on the row a link (or the parent) points to, decided by every grant of that permission:
node: (resource(Node, {
actions: ["read", "share"],
links: {
drive: { field: "driveId", resource: "drive" },
folder: { field: "folderId", resource: "folder" },
},
}),
allow(permissions.node.read, {
to: inherit(permissions.drive.read, { through: ["drive"] }),
}));
allow(permissions.node.share, {
to: inherit(permissions.drive.read, { through: ["folder", "drive"] }),
where: { locked: false },
});through is 'parent' (the row's parent must be on the permission's resource) or a list of link names ending on it, as for relation(). The target row's own denies, conditions and requires apply, since the target is decided as can(permission, targetRow) would decide it. A restricted row inherits nothing through a path its restricted closes, and a row below it inherits nothing through a closed link. definePolicy rejects inherit() on a deny, on a collection action, for an undeclared permission, for a path that does not reach the permission's resource, and a chain of inherit() grants that comes back to a permission it started from.
In process the instance reads the target row through RelationSource.row and caches it like any other graph fact; memoryRelations answers it from rows. A source without row, or a read that fails, denies with relation-unavailable. The grant is server-only in snapshots, stays out of where() (partial: true) and whoCan (complete: false), and never matches as an approver or a delegation from, because each needs a row.
permdock rls generate compiles the grant to "driveId"::text in (select permitted_drive_rows('drive.read')), carried over each further link by its link helper, and writes permitted_<resource>_rows for every resource an inherit() targets whether or not rls.rowHelpers lists it. Each targeted helper is first written as an empty stub, so helpers that call each other are created in any order. The generated policy applies to authenticated, so an anyone() grant on the target does not reach anon through it.
Resource roles down a tree
A resource role held on an instance of a self-parented resource reaches everything below it: a folderAdmin membership on eng applies to eng, to its subfolders, and to documents whose parent is one of them. In process this needs a relations source; without one the role applies only to the instance it names. RLS applies resource roles to that instance only (see Row-level security).
Evaluation
A graph grant reads facts the row does not carry, so the instance asks a RelationSource:
type RelationSource = {
ancestors(query: {
resource: string;
id: string;
through: "parent";
depth: number;
}): RelationChain | Promise<RelationChain>;
related(query: {
resource: string;
id: string;
relation: string;
}): RelationHolder[] | Promise<RelationHolder[]>;
row?(query: {
resource: string;
id: string;
}): Row | null | undefined | Promise<Row | null | undefined>;
};
createPermDock(policy, user, { relations: source });ancestors returns the chain nearest first, at most depth entries, each with its own restricted flag, plus truncated when the chain goes on to a row that exists. A restricted row ends the parent chain after itself only when its restricted closes 'parent'; otherwise the chain goes on and keeps the flags, which the check below a closed link reads. With a link name as through it returns the one instance the link points to. related returns the holders of one concrete relation on one object: { principal: { id } } or { group: { resource, id, relation } }, with optional startsAt / expiresAt in seconds. row returns one row by id, or null when there is none, for inherit() grants. The instance expands includes and groups itself, so a source never answers for an implied relation. memoryRelations(permissions, { rows, edges }) is the in-process source; testRelationSource in permdock/testing checks any other (extension interfaces).
Answers are cached per instance, never at module level: a request's can, filter, whoCan and the instances tenant(), team() and simulate() derive share one cache, and the next request starts empty. Evaluation stays synchronous. A source that answers synchronously is used directly; a Promise the instance has not loaded yet denies, and await permdock.loadRelations(permission, rows) loads what those rows need first:
const permdock = await getPermDock();
await permdock.loadRelations(permissions.doc.read, docs);
const visible = permdock.filter(permissions.doc.read, docs);| Denial reason | When |
|---|---|
relation-depth | The chain has a cycle, or it goes on past depth and no holder was found within it |
relation-unavailable | No relations source, a call that threw or rejected, an answer that is not a chain or a holder list, or a Promise loadRelations did not load |
A graph read that fails fails the whole grant, whatever the rest of its condition says. On a deny, it denies the decision: a deny the graph cannot evaluate is never skipped.
Snapshots, clients and where()
Snapshots carry no graph. A grant whose condition needs the graph, or whose relation has a period, is server-only in the snapshot (portable: false, no where), so a client check returns opaque-condition and the client stores (React, React Native, Vue, Svelte, Solid) route it to the decision endpoint, as they do for every server-only grant; refresh never carries relation facts. mayAccess stays an optimistic hint and answers true for a graph allow.
permdock.where() keeps graph grants as related nodes, and the ORM compilers turn them into SQL over the same tables RLS reads:
| ORM | How a related node compiles |
|---|---|
| Drizzle, Kysely | toWhere(where, table, { relations }) emits one subquery: the closure table when relations.closure is set, otherwise a recursive walk bounded by the grant's depth |
| Prisma | await resolveRelated(where, { run }) reads the ids with one raw query and replaces each node with in(id, ids); toWhere then compiles as usual |
relations: { tables, closure } maps resource names to tables (default the resource name) and names the closure table, for example 'permdock.permdock_closure'. Without relations the compilers refuse a related node with non-portable-condition (conditions). A relation with a period stays out of where() (partial: true). resolveRelated reads the ids when it runs, so a share added after that call is not in the result.
whoCan
await permdock.whoCan(permission, row) lists who holds a permission on one object and how, for share dialogs and access reviews:
const { holders, complete } = await permdock.whoCan(
permissions.folder.read,
folder,
);
// holders: [{ principal: { id: 'u_vera' }, via: [{ kind: 'share', resource: 'folder', relation: 'viewer', id: 'root' }] }]via is role (with the membership, from MembershipSource.list), relation (a field or principal relation, on the object or an ancestor) or share (an edge-table row, with its expiresAt, and group when the row named a group the holder is a member of). Implied relations are expanded, so an editor is listed under a viewer grant with the editor relation in via. Each candidate is confirmed with a full decision, so a deny, a restricted branch or an expired share removes them. It lists and never grants. complete is false whenever a grantee cannot be enumerated: a global role, a plan, anyone, authenticated, an assurance or actor grantee, a custom-role grant, a resource role, a membership source without list, a relation the source could not answer, or a grantee kind this build does not know. A false list may miss holders, and is never presented as complete.
Row-level security
permdock rls generate emits <schema>.permdock_closure(resource, ancestor, descendant, depth), kept current by statement triggers on each self-parented table the policy walks, and compiles a graph grant to one uncorrelated subquery over it. match becomes a column predicate, includes an or over the implying relations, groups a recursive CTE bounded at 16 levels, and each link a security definer helper permdock_link_<resource>_<link>(ids text[]). See closure table and permdock rls verify --tree.
Limits of the SQL side:
- Resource roles apply to the instance the membership names, not to its descendants.
- An edge
expiresAtcompares againstnow()and must be atimestamptzcolumn. - Helper names use the resource's snake_case name:
chatThreadgetspermitted_chat_thread_ids. A graph resource namedteamclashes with the implicitteamscope (doctor PD032); declarescopesor rename the resource. - Hosted grants (
permdock/cloud) reject a link list inthrough.
External graphs
OpenFGA and SpiceDB are recipes, not package entries (ecosystem index). A RelationSource over either answers related with a read of the tuples on one object (OpenFGA read, SpiceDB ReadRelationships) and ancestors with the parent tuples walked up to depth; the model keeps the graph, PermDock keeps the decision, and RLS still needs the rows in Postgres. To hand the whole decision to the external engine instead, permdock/pdp ships openfga() and spicedb() decision providers.
Why
- Parent chains, not a tuple store. A Zanzibar-style relation store leaves the subset the database can enforce. A parent pointer on the row and relations on the resource node compile to a closure table and one subquery, so the same grant is enforced by
can, by the database and byrls verify. Teams that already run OpenFGA or SpiceDB plug them in as a source. - Depth is bounded, and past it means denied. A chain with no bound is a denial-of-service vector and a cycle is an infinite loop. The walk reads at most
depthancestors (16 by default, 32 at most); a holder within them grants, anything beyond is never read, and when nothing within them holds the relation the denial saysrelation-depthso an operator can tell a cut-off chain from a missing share. A cycle always denies. In Postgres the triggers refuse a write that makes a row its own ancestor, so the database never holds a cycle to disagree about. - Restricted stops inheritance at the row. The common need is a folder inside a shared tree that only its own members see (HR under the company root). Stopping the walk at the restricted row, after reading its own relations, gives that without a deny, and the closure triggers stop at the same row, so the database agrees.
- A restricted row names what it stops, and a closed link stays closed below it. A file tree inside a drive has two inherited paths: shares on folders above, and access to the drive. Some apps want a restricted folder hidden from shares above it but still visible to the drive's members; others want it hidden from both. One column that closed every path forced the second meaning on everyone, and checked a link only on the row itself, so the restricted folder disappeared from the drive while its unrestricted children stayed visible through their own drive link.
stopslets the resource choose, and checking the rows above for a closed link makes a subtree follow its restricted root. The string form keeps closing every path and now applies the check below too: this is a breaking change for trees that relied on children of a restricted row staying reachable through a link, and it fails closed. The check reads the closure in Postgres and the parent chain in process, both 32 hops deep, the deepest any walk goes. - Evaluation stays synchronous.
canruns on every render and insidefilter; making it async for one grant kind would split every adapter. The limit store set the precedent: a Promise is never awaited on the decision path.loadRelationsis the explicit async step, and it only fills the cache. - Graph facts stay on the server. A snapshot that listed every folder a user can see would be a cache that goes stale on every share and leaks the tree's shape, so graph grants resolve on the server. Relation periods follow them, since a client clock is not the server's.
- Roles, implication, groups and links, but no tuple store. These are the four patterns the parent chain could not express (Google Drive's viewer-implies-commenter, teams as members of folders, a role column on a membership table, a document reviewed by its folder's owning team). Each compiles to a column predicate, an
or, a bounded recursive CTE or a to-one join, socan, the ORM filters and RLS still agree. Arbitrary userset rewrites and intersections over relations stay with OpenFGA and SpiceDB. - Group nesting counts per resource. A team inside a team inside a team is a chain of the same resource and gets the same 16-level bound as a parent chain. Crossing to another resource (a folder shared with a team) starts a new count, since that crossing is fixed by the declaration and cannot loop;
definePermissionsrejects the declarations that could. - A shared group is walked once. Teams that each contain the same sub-teams form a lattice whose paths grow exponentially with its depth, while its groups grow linearly.
canremembers every group it has walked and the depth budget it had, and skips one already walked with as much budget, since that walk's verdict is already counted;whoCankeeps each group's member list per budget. The cycle check stays per path, so a cycle still denies withrelation-depth. - Inheritance names a permission, not a relation. "A node is readable where its drive is" is a statement about every way the drive becomes readable. Restating each of them as relation grants on the node, or ORing helper calls in hand-written policies, drifts as soon as the drive gains a grant.
inherit()asks the target permission itself, through the same row helper in SQL and the same decision in process, so the two stay one definition. Requiring the inheritance graph to be acyclic keeps both the recursion in process and the helper calls in SQL finite. - ORM filters use SQL, Prisma uses ids. Drizzle and Kysely accept raw SQL fragments inside a typed query, so the graph subquery runs inside the list query. Prisma's
whereinput has no raw fragment, soresolveRelatedreads the ids first. The same SQL builder feeds all three and the RLS helpers, andtests/integrationchecks each againstcan(). - whoCan says when it is incomplete. An access review that silently drops holders is worse than none. Global roles and plans are not enumerable from a row, so the list says
complete: falseinstead of guessing.
Last updated on
Ownership and audiences
Role rules as policy. Every organization keeps an owner (min, max, transferOnly), a role lists the roles it may hand out (assigns), membership kinds decide who may hold a role (for), decideRoleChange checks an assign, revoke or transfer, generated RLS enforces the counts at commit, and audiences tell the UI which surfaces a subject uses.
Elevated access
Time-bound, attributed, justified access. Just-in-time role activation makes a role eligible-only and mints a short-lived membership, break-glass is the only deny override and carries obligations, and support access lets a vendor act inside a tenant with consent and an actor. Purpose of use is a decision input.