The catalog
The catalog is your application’s single source of truth for every permission key, every scope type, and the relationships between them. You declare it explicitly in TypeScript usingdefineCatalog. Nothing is inferred from call sites, and nothing is configured in a dashboard.
docs, docs.files) are inferred from the keys, never declared.
defineCatalog throws at boot if the catalog is structurally invalid (bad segments, undeclared scope types, a key that is also a group path). That means a broken catalog fails fast, before your server accepts any requests. Conventions such as key depth, the naming floor and nav wiring are reported by lintCatalog and failed by @alfiz/verify in CI instead, so house style stays a setting you control.
Template-literal types flow from the catalog literal to every call site. The type KeyOf<typeof catalog> resolves to the union "docs.files.read" | "docs.files.update_file" | "docs.files.delete", plus the built-in alfiz_internal.* keys. Pass a misspelled key to alfiz.can() and TypeScript reports a compile-time error. The runtime evaluator also verifies every key before evaluating it, which closes the hole where a runtime-string path could pass for anyone holding a covering wildcard.
Beyond that, Alfiz has house conventions (a read leaf per group, <verb>_<noun> action names, a key depth) checked by lintCatalog and reported in CI by @alfiz/verify. They are preferences with a default. Nothing about evaluation, grants, patterns, or types reads them, and conventions: { depth: "any" } turns the depth check off entirely. Adopting Alfiz in an existing codebase means setting them to match your keys, not renaming your keys to match them.
Grant rows
A grant row is the atomic unit of access in Alfiz. Every form of access reduces to the same tuple, whether it is an admin assignment, a role, a group membership, public (everyone) access, or a time-bound elevation:
app.createGrant and bulk-import them with app.createGrants. Every write requires a provenance object that names the actor and the reason; the application writes an audit entry automatically before returning.
kind of change ("admin", "request", "import", "system", etc.) and the actor who made it. It is validated at the top of every write, before storage is touched, so a bad provenance cannot leave a written row with no audit entry.
Because grants key on subject and scope strings (not foreign keys), deleting a principal or a resource in your database does not automatically clean up its grants. Wire app.deleteSubject(subject, provenance) into your user-deletion path and app.deleteScope(scope, provenance) into your resource-deletion path. A reused ID would inherit stranded access without this discipline.
Subjects
A subject identifies who is being granted access. Alfiz recognizes several subject kinds:
When Alfiz evaluates a check for a user, it first computes the subject closure: the complete set of subject strings that apply to that user at that moment. A user who is a member of
group:eng, which is itself a child of group:staff, has a closure that includes user:<id>, group:eng, group:staff, everyone, and any relevant org: and manager-chain subjects.
Subject closures are cached (default 30-second TTL) and busted immediately by invalidation events, so adding a user to a group or revoking a role takes effect within the TTL without requiring a restart or a manual cache flush. In multi-node or serverless deployments, opt into cross-process revalidation to tighten that bound to a configurable window regardless of TTL. See createAlfizClient for revalidateAfterMs and cacheStore.
Scopes
A scope identifies where a grant applies. Alfiz uses three levels:- Global (
*). The grant applies everywhere. A globaldocs.files.readgrant lets the subject read any file in the system. - Scope instance. A specific resource, identified by a string like
docs.doc:123ordocs.folder:42. The format is<scope-type>:<id>. - Scope type. Declared in the catalog (e.g.
docs.docwithparent: "docs.folder"). The parent relationship defines the hierarchy.
docs.doc:123, Alfiz builds the object closure: the ancestor chain from that document up to *. For a document nested inside a folder, the chain is ["docs.doc:123", "docs.folder:42", "*"]. A grant on any node in that chain satisfies the check.
app.notifyScopeMoved(scope), so moving a sensitive document into a restricted folder takes effect at once if you pair the move with that call.
Grants are stored once at the node where they are made. Alfiz never fans grants out to descendants, which means adding a document to a folder does not require rewriting any rows.
The check
alfiz.can(principal, key, scope?) asks: does this principal’s subject closure intersect the access granted for this key at any node in this scope’s object closure?
Under the hood the evaluator:
- Fetches the subject closure (from cache or provider).
- Builds the object closure: the ancestor chain from the target scope to
*. - Looks for a grant row whose subject is in the closure, whose pattern matches the key, whose scope is in the object closure, and whose expiry (if any) has not passed.
- Checks whether any personal revoke for the user suppresses that grant.
docs.files.* grant matches docs.files.read today and will also match any new key you add under docs.files tomorrow, without touching any existing grant rows.
can.fresh bypasses both the subject cache and the object cache. Pair it with every destructive action and every just-in-time elevation where the bounded staleness of the cached path is not acceptable.
can repeatedly. A snapshot fetches subject and object data once, then checks synchronously. That is a stronger consistency guarantee than repeated async calls, and it is safe inside .map():
Semantic opinions
Storage, transport and deployment are yours to choose. The authorization semantics below are fixed, and none of them is pluggable.The personal-revoke layer is the only negative in the system. It applies to individual users, not groups or roles. A revoke at a folder scope suppresses access at that folder and every document inside it, but does not affect other subtrees.