Skip to main content
Adopting Alfiz in an application that already has permissions rarely means a rewrite. Your keys usually survive, your assignments import in one call, and your call sites change shape without changing meaning. One step does take deliberate work, and every migration underestimates it: splitting the roles that currently grant globally. This page is the brownfield path. If you are starting fresh, the quickstart is shorter.

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.
Splitting those roles is the migration. Until it is done, adding scopes has no effect on who can do what.
Cut each role that mixes org-wide authority with per-resource authority into two:
Rule of thumb while cutting: for each pattern in an old role, ask “should a holder be able to do this at a resource they have no relationship with?” Yes → the global half. No → the scoped half.

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.
One role, two grant sites:
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 one createGrants 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.
Where a SQL data migration must reference a role or group by id, pass the id yourself (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 your users 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:
For a hierarchical list page you cannot know your row ids until after the query, so extend the snapshot once the query returns:
For large result sets, push the filter into the database with 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:
CLI name lists are added to the built-ins, so you never need to restate 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 taking permission: 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.
Provenance is validated the same way, at the write path: a missing 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.
After that, the capabilities your old system lacked are single grant rows away rather than features to build: group grants, time-bound elevation, access requests, per-resource roles.