Skip to content

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 the under URI 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 use toRegExp() or filter in memory with allows().

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.
  • batchCheckDetailed returns { 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 with viaGroup exactly when it passes through that group. Without permission, 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 by principal if you show people once.
  • Use inherited and resource to show "inherited from /documents" and to send people to the right place to change it. Revoking on /documents/42 doesn'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