1. Keep your keys
Declare your existing permission strings as they are. The only structural requirement is that each key’s first segment is a namespace you declare. Everything else about the shape is house convention, configurable in one line. A migration that renames 400 call sites on day one is a migration that stalls. Set the conventions to match your keys:"any" is a perfectly good permanent answer. Nothing in evaluation depends on how many segments a key has.
2. Everything you are migrating becomes one table
Alfiz has one atomic unit: the grant row(subject, role-or-pattern, scope, expiry?) with provenance. Map your existing concepts onto it before writing any code.
If a concept does not fit this table, stop and reread it. In every migration so far, the row absorbed it.
3. The crux: a global grant satisfies every scoped check
The global scope* is in every object closure. So can(user, "docs.files.share", "docs.folder:9") passes for anyone holding docs.files.share at *. A global grant is authority everywhere, including every scope you will ever create.
That is obvious once stated. The consequence is the part that bites: your existing roles grant resource-level permissions globally, because a scopeless RBAC system has nowhere else to put them. Import “Editor = may share documents” as a global role, then start writing scoped grants, and scoping changes nothing: every editor still passes every scoped check through the global grant.
Cut each role that mixes org-wide authority with per-resource authority into two:
4. A role’s meaning depends on where it is granted
The same role definition confers different key sets at different grant sites. A scoped grant is restricted to the leaves that declare that scope type; a global grant confers everything the patterns match.
This is what makes one “Folder editor” role safe to hand out per folder. What a scoped grant site can confer is bounded by the catalog, not by the role author’s restraint. It also means “grant role R” is an incomplete sentence during a migration. Always say where.
catalog.appliesAt(key, grantScope) answers the same question programmatically, if you are building a picker that must show what a grant at a given site would actually confer.
5. Import assignments in bulk, with your own ids
Migrating N existing assignments is onecreateGrants call rather than N createGrant calls. Every input is validated before any row is written, so one bad assignment rejects the whole batch instead of half-importing a tenant. Then one audit entry records the batch, and one invalidation event fires per distinct subject.
createRole({ id: "role_folder_editor", … }), createGroup({ id: "editors", … })) so migration SQL and runtime agree on identity with no name-resolution cache. A taken id is a conflict, and never an overwrite.
6. Wire the lifecycle paths, not just the checks
Grants key on subject and scope strings, not foreign keys. Alfiz cannot see yourusers or folders tables, so deleting a row there silently strands the grant rows here. If an id is ever reused, the new principal inherits the old one’s access.
deleteSubject on a user: also removes their revokes, their stored record, grants held by their implicit-group subjects (directs: / orgof:), and cancels their pending requests. deleteScope sweeps one scope id. Descendants are separate rows, so call it per deleted resource when removing a subtree.
For offboarding that must be reversible, deactivate instead: app.setUserActive(userId, false, provenance). An inactive principal evaluates to no access on every check shape. Delete only when the id itself is being retired.
7. Put the render path on a snapshot
A server-rendered page performs hundreds of conditional-UI checks inside.map() callbacks and render helpers that cannot be async. Take one snapshot per request and check synchronously:
grantedScopes instead of resolving every row.
8. Turn the verifier on last, and configure it for your wrappers
Your codebase gates through its own wrappers (assertCanEditFile, gateDestructiveAction, …), which the conventions encourage. Tell the verifier, or every wrapped action reads as ungated:
can and require. Surfaces that authenticate outside the catalog by design opt out in the file header, with a reason. See static verification.
9. Runtime strings are checked against the catalog
Typed keys and the verifier cover every literal call site. The paths they cannot see, such as nav tables, config, and generic wrappers takingpermission: string, are checked at runtime instead. A key or pattern the catalog does not declare raises UnknownPermissionError, a programming error your framework should map to 500 rather than 403.
Both silent behaviours this replaces were wrong in ways that were nearly impossible to notice:
Two consequences worth planning for:
- A bare group path is never a valid check. For visibility, the subtree pattern is
docs.*. For a gate, groups are folders, so gate on a leaf. - Keys must exist in the compiled-in catalog. A shared component checking a key from a namespace this application neither owns nor imports will say so instead of quietly denying.
actorUserId is rejected as a validation write rejection before any row is written, so nothing fails later inside the audit writer.
The checklist
1
Catalog
Keys unchanged,
namespaces declared, conventions: { depth } set to match your keys (or "any"), scope types declared (parent: null only if instances are truly flat), and group-level scopes on scoped groups.2
Roles imported split
Global halves and scoped halves, per the crux above. This is the step that makes scoping real.
3
Assignments imported
One
createGrants call under an import provenance, with well-known ids supplied by you.4
Lifecycle paths wired
Every delete path paired with
deleteSubject / deleteScope, every move path with notifyScopeMoved, offboarding with setUserActive.5
Render path on snapshot
Hierarchical list pages resolve after the query or push the filter down; actions gate on
can and can.fresh.6
Runtime-string paths audited
Bare group paths fixed, and
UnknownPermissionError mapped to 500 rather than 403.7
alfiz-verify in CI
Your wrappers configured, out-of-domain files pragma’d in the header, and the remaining error count at zero.