Filtering lists and building share dialogs¶
Check answers one question about one object. Lists and share dialogs need different questions:
| You want to… | Use | SDK (from 0.2.0) |
|---|---|---|
| Show only the rows a user may see, filtered in your database | ListAccess | listAccess() → toPrefixes(), toRegExp() |
| Filter rows you already loaded | ListAccess, or BatchCheck | allows(), batchCheck() |
| Show who can access an object | ListPrincipalsWithAccess | whoHasAccess() |
All three apply exactly the same rules as Check: inheritance, the _ wildcard, roles and
groups.
The examples use the TypeScript SDK. The methods on this page are available from 0.2.0. The API reference has the same calls over REST and gRPC.
Permio returns scopes, not records¶
perms.io doesn't know your rows. It only knows the grants you made. So ListAccess can't return "documents 4, 7
and 9". It returns scopes: the places in your URI tree where the principal holds the permission.
Because a grant applies to its URI and everything beneath it,
each scope stands for a whole subtree. A grant on /foo/bar gives the scope /foo/bar, and it covers /foo/bar,
/foo/bar/baz and anything later created below it. Your code turns the scopes into a filter on your own data.
grants for alice (document.read) scopes under /documents
/documents/4 → /documents/4 SUBTREE
/documents/7 → /documents/7 SUBTREE
/documents/_/comments → /documents/_/comments PATTERN
There are three possible answers:
coversAll: the principal holds the permission on theunderURI itself, so on everything below it. Don't filter at all.- SUBTREE scopes: a URI and everything below it. These turn into prefix filters.
- PATTERN scopes: a grant made on a URI with
_wildcard segments, such as/documents/_/comments. Each_matches exactly one segment, and everything below a match is covered too. No prefix can express this, so usetoRegExp()or filter in memory withallows().
To use any of the filters below, store each row's resource URI (here in a path column), built the same way as
when you grant, for example with resource().
Ask once with listAccess¶
import { Permio } from "permio";
const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default" });
const access = await permio.listAccess({
principal: userId,
permission: "document.read",
under: "/documents", // only resources at or below this URI are considered
});
listAccess follows every page and returns an AccessScopes object. The parts you use for filtering:
| Member | Use |
|---|---|
coversAll |
true: no filter needed. Check it first. |
toPrefixes() |
The SUBTREE URIs. Prefixes already covered by a shorter one are dropped. |
hasPatterns / patterns() |
Whether there are PATTERN scopes, and their URIs. |
toRegExp() |
One anchored RegExp that matches exactly the covered URIs, patterns included. |
allows(uri) |
Whether one URI is covered. |
The answer is only about resources at or below under. For a URI outside under, allows() returning false
means "not asked", not "denied".
Match on segment boundaries
A prefix p stands for p itself and everything below it. Match path = p OR path starts with p + '/'.
A bare "starts with p" is wrong: the scope /documents/1 would also match /documents/12.
Don't use LIKE p || '%' either. In SQL LIKE, _ and % are wildcards, and _ is common in IDs, so it
matches rows it shouldn't unless you escape them.
PostgreSQL¶
starts_with (PostgreSQL 11+) compares literally, so it is safe with any ID:
const prefixes = access.toPrefixes();
const { rows } = access.coversAll
? await pool.query("SELECT * FROM documents")
: await pool.query(
`SELECT * FROM documents
WHERE path = ANY($1::text[])
OR EXISTS (SELECT 1 FROM unnest($1::text[]) AS p WHERE starts_with(path, p || '/'))
OR ($2::text IS NOT NULL AND path ~ $2)`,
[prefixes, access.hasPatterns ? access.toRegExp().source : null],
);
The last condition handles PATTERN scopes. toRegExp().source is valid PostgreSQL regular-expression syntax. On its
own, path ~ $1 with toRegExp().source also works for every scope, but it can't use an index.
Prisma¶
Prisma's startsWith is literal, so the boundary match is safe:
let docs: Document[];
if (access.coversAll) {
docs = await prisma.document.findMany();
} else if (!access.hasPatterns) {
const prefixes = access.toPrefixes();
docs = await prisma.document.findMany({
where: { OR: [{ path: { in: prefixes } }, ...prefixes.map((p) => ({ path: { startsWith: p + "/" } }))] },
});
} else {
// Prisma has no portable regex filter: load the candidates under /documents and keep the allowed ones.
const candidates = await prisma.document.findMany({ where: { path: { startsWith: "/documents/" } } });
docs = candidates.filter((d) => access.allows(d.path));
}
If your rows have only an ID and their URI is /documents/<id>, the prefixes exactly one segment below under are
the IDs the user can read (when hasPatterns is false; a pattern such as /documents/_ stands for every
document):
const ids = access
.toPrefixes()
.filter((p) => p.split("/").length === 3)
.map((p) => decodeURIComponent(p.split("/")[2]!));
const docs = access.coversAll
? await prisma.document.findMany()
: await prisma.document.findMany({ where: { id: { in: ids } } });
A deeper prefix such as /documents/7/comments gives access below document 7, not to document 7 itself, so it is
rightly left out.
MongoDB¶
toRegExp() covers SUBTREE, PATTERN and EXACT scopes in one anchored regular expression:
const filter = access.coversAll ? {} : { path: access.toRegExp() };
const docs = await db.collection("documents").find(filter).toArray();
With no scopes at all, toRegExp() matches nothing.
Filter rows you already have¶
allows() in memory¶
When the rows are already loaded, test each URI against the scopes. The number of calls to perms.io doesn't depend on
how many rows you have, and allows() handles PATTERN scopes:
import { resource } from "permio";
const visible = docs.filter((d) => access.allows(resource("documents", d.id)));
batchCheck for a fetched page¶
For a short list, such as one page of search results, batchCheck checks every resource separately and returns a
Map from URI to true/false:
const uris = docs.map((d) => resource("documents", d.id));
const allowed = await permio.batchCheck({ principal: userId, resources: uris, permission: "document.read" });
const visible = docs.filter((d) => allowed.get(resource("documents", d.id)));
- A resource is allowed only if the principal holds every listed permission on it.
- Each API call takes up to 100 resources. The SDK splits longer lists into parallel calls.
batchCheckDetailedreturns{ resource, allowed, missing }for each resource, in request order.
Filtering after fetching means a page can come back with fewer rows than you asked for. When you need full pages,
filter in the database with listAccess.
API key access
batchCheck needs permission.check, like check. listAccess also needs principal.assignment.get on the
principal. A check-access key has both.
Build a share dialog with whoHasAccess¶
A share dialog shows who can already open an object. whoHasAccess lists every principal with a grant on the
resource, on an ancestor of it, or on a _ wildcard URI that matches it, with the grants that give the access:
const entries = await permio.whoHasAccess({
resource: "/documents/42",
permission: "document.read", // optional: only grants that confer this permission
expandGroups: true, // also list each group's members
});
for (const e of entries) {
const how = e.via
.map((g) => (g.source.type === "role" ? g.source.name : "direct") + (g.inherited ? ` from ${g.resource}` : ""))
.join(", ");
console.log(e.principal, e.isGroup ? "(group)" : "", e.viaGroup ? `via ${e.viaGroup}` : "", how);
}
// For example:
// alice viewer from /documents
// group:editors (group) direct
// bob via editors direct
Each entry (AccessEntry) has:
| Field | Meaning |
|---|---|
principal |
The principal ID. Groups appear as group:<slug>. |
isGroup |
true for a group. |
viaGroup |
For entries added by expandGroups: the slug of the group the access comes through. undefined for the principal's own grants. |
via |
The grants that give the access, most specific first: resource (where the grant was made), inherited, match ("exact", "ancestor" or "wildcard") and source (a role with its id and name, or "direct"). |
Tips for the dialog:
- Pass
permission. A principal is then listed exactly when a check for it passes through its own grants, and an entry withviaGroupexactly when it passes through that group. Withoutpermission, any grant on the resource or above counts, whatever it allows. - Without
expandGroups, group members aren't listed; only the group is. With it, a member of two groups appears once per group, so de-duplicate byprincipalif you show people once. - Use
inheritedandresourceto show "inherited from/documents" and to send people to the right place to change it. Revoking on/documents/42doesn't remove a grant made on/documents. - Entries are ordered by principal ID.
whoHasAccess needs principal.list and principal.assignment.list on the project, which a check-access key has.
Choosing an approach¶
| Situation | Approach |
|---|---|
| Paginated list from your database | listAccess + toPrefixes() (and toRegExp() if hasPatterns) in the query |
| Small list already in memory | listAccess + allows(), or batchCheck |
| Buttons for a page of results ("can edit?") | batchCheck with that permission |
| Share dialog or access review | whoHasAccess with permission and expandGroups |
| One object, one request | check |