Subject kinds
Subject identities are typed string encodings. Every subject id encodes its kind as a prefix so it can live in grant rows, indexes, and wire formats without a parallel object model:
Helper constructors produce the correctly-encoded ids:
The subject closure
When Alfiz evaluates a check for a user, it first computes the subject closure, the complete transitive set of subjects that user belongs to. The function that computes it takes a plain data input and is a pure function with no I/O:user:<userId>, the user themselves- Every explicit group they belong to (
group:<id>), breadth-first - Every ancestor of those groups, transitively breadth-first
- Implicit reporting groups:
directs:<directManager>andorgof:<m>for every manager in the chain - Every organization they belong to (
org:<id>) everyone
group:editors, and group:editors has parent group:staff, Alice’s subject closure is:
group:staff reaches Alice automatically, with no direct grant needed.
everyone is not special-cased
The everyone subject is the mechanism for public or default access. “Anyone can read the public docs” needs no special flag and no mode switch. It is an ordinary grant row:
everyone is present in every subject closure, including that of unauthenticated or not-yet-provisioned users, this grant reaches all of them through the standard evaluation path. No special-casing is needed in the check engine.
Groups
Groups are named bundles of subjects. You grant access to a group with the samecreateGrant call you use for a user or any other subject:
group:editors now holds docs.files.update_file at that scope through their subject closure. Group membership is stored on the user record as groupIds: string[].
Manage membership with app.setGroupMembership(userId, groupIds, provenance):
Groups only widen access, and never revoke. The only negative layer in Alfiz is the personal revoke, which lives on individual users. That constraint is deliberate.
Group nesting
A group may declare parent groups. It inherits the union of all its ancestors’ access. The parent relationship is a DAG, and Alfiz enforces acyclicity transactionally at every edge write. Attempts to insert a cycle reject the write with aProviderWriteRejectedError whose code is "graph_cycle", naming the full path:
UserGroup interface that backs groups in storage is:
id, so renaming a group with app.updateGroup(...) never breaks any existing grant.
Reporting edges and implicit groups
When you store reporting edges (userId → managerUserId) with app.setReportingEdge(...), Alfiz derives two implicit group subjects for every manager automatically:
directs:<managerUserId>. Everyone whosereportsToedge points directly at that managerorgof:<managerUserId>. The full transitive report tree under that manager
directs:manager_jane is computed dynamically from the reporting tree, so there is no separate membership list to maintain. When an employee’s manager changes, the implicit group membership updates automatically and the affected subject closures are invalidated.
Cache dynamics
Subject closures are cached with a default ~30-second TTL. Event-driven invalidation busts the cache immediately on:- Group membership changes (
setGroupMembership) - Group parentage changes (
setGroupParents) - Reporting edge changes (
setReportingEdge) - User activation/deactivation (
setUserActive) - Grant or revoke writes that touch a user-subject
can.fresh(principal, key, scope) to bypass the cache entirely.
Showing a user’s subject closure
Callalfiz.explain(principal, key, scope) to see the full picture for a specific check, including which grants and revokes matched:
SubjectAccessData, reachable through app.getSubjectAccess(principal), contains the raw closure array, which is the full list of subject ids the check engine evaluated against: