Skip to main content
Every grant row carries a scope: the resource or level at which that grant applies. Alfiz lets you grant access at any point in your resource hierarchy, from a single document all the way up to the global scope. At check time, Alfiz walks the ancestor chain of the target resource and considers a grant valid if it was made at the target itself or at any ancestor, including the global scope *. This means you grant once and the access flows down automatically, without touching every descendant.

The global scope

The global scope is the string constant "*". A grant with no scope field, or one explicitly set to "*", applies everywhere:
Because * is present in every object closure, a global grant satisfies any scoped check. Granting docs.files.read at * means the subject can read every document in the system, regardless of which folder it lives in.

Scope instance ids

A scope instance id takes the form <scopeType>:<instanceId>, for example docs.doc:123 or docs.folder:9. The scope type is a dot-separated key that matches a declaration in the catalog; the instance id is an opaque string from your database.
The hierarchy path is never encoded in the id. Moving a document from one folder to another is a data update to its parent pointer in your database. Every grant on it follows automatically, because the check engine resolves the ancestry dynamically at check time.

The ancestry resolver

Alfiz cannot know your resource hierarchy, because it lives in your tables. You expose it to the check engine through an AncestryResolver:
The resolver must return the ancestor chain of the given scope, ordered nearest-first, ending at the global scope *. The chain excludes the scope itself. You supply this to the Application at construction time:
The parentPointerResolver helper wraps a synchronous parent-pointer lookup into a conforming resolver. It walks up the parent chain breadth-first, deduplicates, and guarantees that * appears last. It handles both single-parent and multi-parent hierarchies:
For docs.doc:123 nested inside docs.folder:9 nested inside docs.folder:2, the resolver returns ["docs.folder:9", "docs.folder:2"] and Alfiz appends * to produce the full object closure.

Check-time ancestry walk

When you call alfiz.can(principal, key, scope), Alfiz computes the object closure of scope, the set a grant’s own scope must intersect to be relevant:
For a document at docs.doc:123 in docs.folder:9 in docs.folder:2, the object closure is:
A grant anywhere in that list covers the target. Alfiz calls your AncestryResolver once per closure, handing over the target scope and receiving the whole chain back. The O(depth) cost is inside your resolver, which is why parentPointerResolver (walking one pointer per level) and a materialized-path or closure-table lookup (one query) differ so much at depth.
Scope types declared parent: null are flat by declaration: their object closure is always [scope, "*"]. That is what lets a snapshot check them synchronously without consulting the resolver, while the async client.can path still goes through the resolver. The shortcut also requires multiParent: false: a type declared { parent: null, multiParent: true } is treated as hierarchical.

The listing problem

Point checks (can()) answer “can Alice read this document?” in one check and one object closure walk. Listing pages answer a different question: “which documents can Alice read?” Naive per-row checking is an N+1 death: you cannot load every document and call can() on each one. The correct pattern is:
  1. Compute the set of scopes at which Alice holds the relevant grant, using alfiz.grantedScopes(principal, key).
  2. Push that set into your database as a filter, using your resource table’s ancestry index.
Then use planListing to turn the granted/revoked sets into an actionable plan:

Materialized path queries

If your resource table stores ancestry in a materialized path column (e.g. /docs.folder:2/docs.folder:9/docs.doc:123/), use the matPathCondition helper to generate the SQL fragment:
For Prisma users, prismaMatPathWhere produces a where object directly:

Closure table queries

If your resource table uses a closure table (one row per ancestor–descendant pair), use closureTableCondition:
Never short-circuit and run an unfiltered query when planListing returns { mode: "none" }. A global revoke or zero matching grants means the principal is provably allowed nothing. Treat none as an empty result and return immediately.

Object closure caching

Ancestor chains are cached with a default 60-second TTL. The cache busts immediately when you call app.notifyScopeMoved(scope). Call this from the same code path that changes a parent pointer in your database:
Without this call, the cache TTL (60 seconds by default) bounds how long a stale ancestor chain can persist. For a sensitive operation such as moving a confidential document into a restricted folder, the old chain surviving for up to a minute is unacceptable.

Using can.fresh after moves

For any operation that follows a move, bypass the object closure cache entirely:
can.fresh re-resolves the full ancestry chain on every call, skipping all caches. Use it for destructive actions and security-critical post-move checks.

Scope deletion

When you delete a resource, call app.deleteScope(scope, provenance) from the same code path. This removes every grant and personal revoke at that scope, and cancels any pending access requests targeting it:
Grants at descendant scopes are separate rows, and Alfiz does not enumerate your subtree. When you delete a folder and all its contents, call deleteScope once per deleted resource id.