*. 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:
* 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 anAncestryResolver:
*. The chain excludes the scope itself. You supply this to the Application at construction time:
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:
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 callalfiz.can(principal, key, scope), Alfiz computes the object closure of scope, the set a grant’s own scope must intersect to be relevant:
docs.doc:123 in docs.folder:9 in docs.folder:2, the object closure is:
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.
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:
- Compute the set of scopes at which Alice holds the relevant grant, using
alfiz.grantedScopes(principal, key). - Push that set into your database as a filter, using your resource table’s ancestry index.
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:
prismaMatPathWhere produces a where object directly:
Closure table queries
If your resource table uses a closure table (one row per ancestor–descendant pair), useclosureTableCondition:
Object closure caching
Ancestor chains are cached with a default 60-second TTL. The cache busts immediately when you callapp.notifyScopeMoved(scope). Call this from the same code path that changes a parent pointer in your database:
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, callapp.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.