Filter Lists by Access
A check answers whether one person may act at one node. A list needs the other direction: which nodes may this person see. The SDK answers it from the cache it already holds, in the shape your query wants.
The question a list asks
Every resource in a hierarchical application belongs to a node, and a person may see a resource when they hold the permission at that node or at one above it. A list, then, is every resource whose node is beneath one of the person's grant roots. The SDK already holds those roots and the tree to answer checks; grantRoots and accessibleNodes turn them into the filter, with no call to Canopy on the request path.
The roots, for a closure table or a path
grantRoots(identityId, permission) answers the nodes the permission is granted at, in effect now, reduced to the ones no other root already covers: a grant at a parent makes a grant at its child redundant. It also says whether one of them is the Environment's root, envWide, in which case every node qualifies and the query needs no filter at all. This is the answer for an application that keeps a closure table or a materialised path, which can filter by beneath one of these without an expanded set.
const { roots, envWide } = await authorizer.grantRoots(identityId, "reports.view");
if (envWide) {
// Every node qualifies: no filter.
}The set, for an IN (...) filter
accessibleNodes(identityId, permission) expands the roots through their descendants into a Set of node ids, for a plain IN (...) filter. The expansion is capped at maxNodes, 10,000 by default: past it the answer carries the roots with nodes: null and complete: false, because an administrator granted near the top of a large tree would otherwise turn every list into a query over thousands of ids. Fall back to the roots then, or raise the cap for that call.
const access = await authorizer.accessibleNodes(identityId, "reports.view");
if (access.envWide) {
return db.reports.findMany();
}
if (!access.complete) {
// Too many nodes for an IN (...) list: filter by access.roots instead.
}
return db.reports.findMany({ where: { nodeId: { in: [...access.nodes] } } });The three shapes in SQL
The same answer, three ways, by how your tables model the hierarchy. Each query reads your own tables; Canopy is not in it.
An IN (...) list, from the set
SELECT * FROM reports
WHERE node_id = ANY($1) -- [...access.nodes]Beneath a root, from a closure table
SELECT r.* FROM reports r
JOIN node_closure c ON c.descendant_id = r.node_id
WHERE c.ancestor_id = ANY($1) -- access.rootsBeneath a root, from a materialised path
SELECT r.* FROM reports r
JOIN nodes n ON n.id = r.node_id
WHERE n.path <@ ANY($1::ltree[]) -- the roots' pathsWith envWide true, drop the WHERE clause. The closure-table and path shapes never need the expanded set, so they are the ones to reach for when a tenant's tree is large.
What someone holds at one node
A screen that renders controls needs the other list: every permission one person holds at one node. permissionsAt(identityId, nodeId) answers it from the same cache, by grant or by inheritance. The API answers it too, as GET /api/v1/identities/{id}/permissions?node_id=.
const held = await authorizer.permissionsAt(identityId, nodeId);
// ["reports.export", "reports.view"]Freshness
The roots and the set come from the same cache a check uses, so they go stale on the same 60-second window, and a grant removed shows up in a shrinking list within it. A grant with an end date is dropped at that moment rather than at the next expiry: the SDK re-reads at the nearest window boundary. authorizer.invalidate(identityId) drops one person's cache now, in this process.
The write still checks its node
A list filtered by access is what to show; it is not an authorization. The write that follows (open, edit, delete) still names its node and still goes through evaluate or the guard, which is the check a change of access reaches first. Filtering decides what a person is offered; the check decides what they may do.
Without the SDK
GET /api/v1/identities/{id}/accessible-nodes?permission= answers the same shape from Canopy, for a process that does not run the SDK or wants a fresh answer: roots, nodes up to max_nodes (10,000 by default, at most 50,000), complete and env_wide. It is resolved by the same rules as evaluate, so a list filtered by it and the check on a write agree.
curl "https://auth.canopy-io.com/api/v1/identities/{id}/accessible-nodes?permission=reports.view" \ -H "X-API-Key: $CANOPY_API_KEY"
Tell us how we can improve this guide.