Permission Transfer
Move a role and permission configuration between databases — typically tuned in development, then applied to production — as a reviewable JSON file.
Administration → Permission Transfer (/admin/permissions/transfer), gated on ADMIN.PERMISSIONS.TRANSFER (migration 235).
The problem it solves
Roles and grants are tuned interactively, but every identifier tying them together — role_id, node_id, tile_id — is a sequence value that means something different in each database. Copying rows by id would attach grants to whatever happened to land on the same number.
So the wire format keys on the code columns instead (role_code, node_code, tile_code, all unique), and the importer resolves them against whatever ids the target database uses.
What is in the bundle
Six sections, written and applied in dependency order so a row can always resolve a reference to something an earlier section created:
permission_nodesrolesdashboard_tilesrole_permissionsrole_default_tilesrole_menu_shortcuts
What is deliberately not in it
| Excluded | Why |
|---|---|
user_roles | Per-user, keyed on a UUID, foreign-keyed to core.auth_users. Accounts differ between environments, so carrying it would either fail on every row or — worse — attach a grant to whoever happened to hold the same id |
| The audit quad | created_by / updated_by are person UUIDs from the source database. The import stamps its own actor |
| Every integer primary key | See above |
Role network rules (core.networks, core.network_cidrs, core.role_network_rules) | Institution policy edited in production, describing that environment's own networks. See Role Networks |
| Role email policies and shared sender addresses | Institution policy edited in production. See Email Sending |
Transferring permissions does not transfer who holds them
Because user_roles is excluded, an import gives the target database the roles and their grants — but nobody is assigned to them by this operation. Assigning users is a separate, deliberate step in the target environment.
Three endpoints, two permission levels
| Action | Endpoint | Level |
|---|---|---|
| Export | GET /authorization/permissions/export | VIEW |
| Preview | POST /authorization/permissions/import/preview | EDIT |
| Apply | POST /authorization/permissions/import/apply | EDIT + superuser |
Reviewing a diff is safe; applying one is not. Apply additionally requires superuser, checked in the handler.
The client's view of the diff is never trusted. The plan is rebuilt server-side from the uploaded file on every call, and apply runs the same planning function preview does, then filters by the operator's per-row decisions.
Reading the preview
Only differing rows appear. Rows that match the target exactly are counted as "unchanged" and not listed — otherwise every import would be thousands of rows of noise hiding the handful that matter.
Each change shows both sides and lets you accept or reject it individually.
Refusals and warnings
| Code | Meaning |
|---|---|
UNRESOLVED_NODE | A grant references a permission node the target does not have |
UNRESOLVED_ROLE | A row references a role code that does not exist and is not being created |
UNRESOLVED_TILE | Likewise for a dashboard tile |
NODE_CYCLE | The node parentage in the file would form a cycle |
SELF_LOCKOUT | Applying this would remove your own access |
LAST_HOLDER_REVOKED | The last holder of a grant would lose it |
MigrationHead is recorded in the bundle's source information and earns its place here: when a grant references a node the target lacks, the usual cause is that the target has not had the migration that creates it, and the plan can say so rather than just reporting an unresolved code.
Self-lockout is checked against all your roles
The importer evaluates every currently-effective role of the person running the import, not just their active one. Checking only the active role would let an operator lock themselves out through a role they were not acting in at the time.
Inheritance-only lockout is covered too: losing access because an inherited grant disappeared counts, not only a directly revoked one.
Scope is written unconditionally
This is the subtle one.
The interactive role editor's BulkUpsert treats an empty scope as "the tree UI did not offer this field, keep what is stored", and COALESCEs against the existing value. That is right for a screen where scope is not always shown.
The importer does not reuse BulkUpsert. In a bundle, the file is the intended state, so scope is written from the file unconditionally and never coalesced against the stored value. Reusing BulkUpsert would make a dev-to-prod scope change unapplyable — you could narrow a grant in development and the change would silently not arrive.
Format versioning
The file carries "format": "sis.permissions.bundle". A JSON file without that exact value is rejected before anything else is read. The format version is checked too: a file written by a newer backend is refused rather than half-understood.
The current format is version 2, which added each role's idle timeout (session_timeout_minutes, migration 266). A version 1 file has no such field, and an import of one keeps the target's value rather than reading the absence as "no limit" and clearing it. The field is omitted when empty, so version 1 checksums still verify.
Suggested procedure
- Tune roles and grants in development.
- Export from development.
- Preview against production and read the diff — particularly any
LAST_HOLDER_REVOKEDorUNRESOLVED_NODErows. - Reject anything you did not intend.
- Apply as a superuser.
- Assign users to any new roles in production — the bundle did not carry that.
Editing a role with many holders can stall the backend
Unrelated to transfer, but the same neighbourhood: editing the grants of a role held by tens of thousands of accounts invalidates a permission cache entry per holder. A SCAN MATCH loop over that fan-out once hung the backend. Prefer changing grants outside peak hours, and be aware that a large role's edit is not a cheap operation.
Related
- Roles
- Permissions — nodes, inheritance, scope
- Audit Logs
- Authorization API