Stay Updated

New tutorials, tips, and Atlassian insights. No spam, unsubscribe anytime.

L
LeanZero

An approachable expert helping teams simplify their Atlassian ecosystems. Sharing knowledge and building community, one solution at a time.

Services

  • Atlassian Migrations
  • Atlassian FastShift
  • Atlassian Maintenance
  • Forge App Development
  • AI Development Consultation

Topics

  • Jira
  • Jira Service Management
  • Confluence
  • Bitbucket
  • Atlassian Forge
  • Cloud Migration
  • Local AI
  • AI Coding
  • All topics

Company

  • Blog
  • Tutorials
  • Contact

Community

  • Join Discord
  • Support this site

© 2026 LeanZero. All rights reserved.

Privacy Policy|Terms of Service|Service Level Agreement|Trust Center
LZ·/PORTFOLIO·REV 2.6
  1. Home
  2. Portfolio
  3. Jira Admin Toolkit
Open-source migration toolkit

Jira Admin Toolkit

Thirteen Node.js tools for the administrative work around a Jira Cloud migration — permission schemes, security levels, project roles, read-only lockdown and org identity clean-up.

View on GitHubRead the manual
Snapshot before mutate Every write has a revert mode Read-only by default Apache-2.0

What silently breaks

Jira Cloud has no bulk endpoints for most administration, and the admin UI has no multi-select. So the work either does not get done, or it gets done by hand over three days with mistakes in it.

One PUT per project, every time

Assigning a permission scheme to 300 projects is 300 individual calls. Adding a group to every permission in 40 schemes is over a thousand. In Data Center this was three Groovy console scripts; that API simply does not exist on Cloud.

Audits that under-report and look fine

If an issue security level hides issues from the auditing account, every count comes back low and reads as good news. "The project is empty" and "my account cannot see this project's issues" are indistinguishable from outside.

A cutover window nobody enforced

A user editing a Data Center issue after the export snapshot was taken produces a Cloud copy that is simply stale. There is no error, no report, and nobody finds out until someone notices a missing comment months later.

What is in the box

Thirteen tools, grouped by the job. Take the site-wide permission-scheme export first — it is the only artefact that captures the whole starting state, and the only moment to take it is before the first write.

The migration window — freeze and thaw

set_read_only_dc

Freeze Data Center for the cutover

Puts every DC project on one read-only permission scheme, saving each project's previous scheme id AND name to a portable backup. Reverts from that backup. Supports PAT, pre-encoded Basic or plain credentials.

set_read_only_cloud

The Cloud counterpart, five modes

set, revert, lockdown (create a read-only scheme and assign it in one shot), revert-from-dc-backup (restore each project's original scheme on Cloud by matching name, because the ids changed), and backfill-missing-schemes.

Permissions and access

bulk_assign_permission_scheme

Export, apply, restore

Snapshot every project's current permission scheme, bulk-assign a target scheme, roll back to the snapshot. The Cloud replacement for three Data Center Groovy console scripts.

copy_permission_schemes_dc_to_cloud

The schemes the migration left behind

Migration assistants only carry permission schemes assigned to a project; unassigned ones are silently dropped. This recreates them on Cloud as literal copies with translated grant holders. Creation only — it assigns nothing.

add_group_to_permission_schemes

One group, every permission, every scheme

Widens every permission type that already has a grant. Permission types with no grants are left alone — it widens what exists rather than inventing policy.

add_group_to_security_levels

One group, every security level

The fix for an audit that says 40,000 issues on DC and shows 31,000 on Cloud. Temporary by design: add for the audit window, remove when it closes.

add_user_to_all_project_roles

Onboard an account everywhere

Adds a user to all roles across all or filtered projects. Paginated, rate-limit-aware, duplicate-safe. For a new administrator, a service account or a migration bot.

Identity and org clean-up

suspend_accounts_by_domain

Suspend a whole email domain

Via the org admin API. Suspension is the default because it is reversible, frees the licence and keeps the data. Discovery uses the search endpoint, which — unlike GET /users — also finds invited-but-unclaimed accounts.

bulk_add_group_customers

A group of a thousand, as JSM customers

Collects every group member and adds them as customers, with an audit phase that set-intersects access-group membership locally instead of doing a thousand per-user lookups.

remove_solo_member_teams

Teams a departing admin left behind

Finds every Atlassian Team whose single sole member is one given person and deletes them. Each team is re-checked at delete time, so a stale plan can never delete the wrong thing.

diagnose_user_picker

Users nobody can find in a picker

Read-only. Cross-tabulates findability by name and by picker against every org directory attribute, so the cause falls out of the data — usually a user-search index gap, not permissions or licensing.

Inspection and housekeeping

fetch_screen_fields

Every tab, every field, on a screen

The answer to "I wrote a value and the user sees nothing" is almost always here: the field is not on the screen. The write succeeded; the API had no reason to warn you.

delete_stale_projects

Sandbox hygiene

Deletes projects not updated in N months. Validates that the target URL looks like a sandbox and requires interactive confirmation. Sandbox only — deliberately awkward to point anywhere else.

How every tool in this repository behaves

The same operating model throughout, deliberately — so that knowing one tool means knowing all of them.

Read-only by default

Every tool's default is a plan. Writing needs an explicit --apply or the absence of --dry-run, and the plan is a file you can read. The plan is also the artefact you put in front of the system owner when you ask.

Snapshot before mutate, name as well as id

Anything that changes an assignment writes the previous state to JSON first, and has a revert mode that consumes it. The backup stores both the id and the name of what it replaced, so it stays useful across an instance boundary where ids change and names survive.

Re-checked immediately before acting

Destructive operations re-verify their precondition at write time. If a team gained a member since the plan was built, it is skipped. A stale plan cannot delete the wrong thing.

Prove the negative before acting on it

A count of zero, an empty list or a 404 does not licence a write until you have proved your credentials can see the object at all — on that same object. Permission and security filters are per object, so a positive control elsewhere proves nothing.

Start here

git clone https://github.com/leanzero-srl/leanzero-jira-admin-toolkit.git
cd leanzero-jira-admin-toolkit/bulk_assign_permission_scheme
npm install
cp .env.example .env      # CLOUD_BASE_URL, CLOUD_API_TOKEN = base64("email:api_token")

# The snapshot is your undo for most of this repository. Take it FIRST.
node main/bulk_assign_permission_scheme.js export

The manual

Every question this repository raises, answered in order: what to snapshot before you change anything, how to freeze and thaw a migration window, how to widen access without inventing policy, and how to prove an admin change did what you intended. 28 sections.

Contents
Before you change anything
  • 01What is the first command I should run?
  • 02Why does this exist at all — can't the admin UI do this?
  • 03This is someone else's production site. What is the rule?
The migration window
  • 04How do I stop people editing Data Center during the cutover?
  • 05After migration, how do I restore each project's original scheme on Cloud?
  • 06Why are some of my permission schemes simply not on Cloud?
Permissions, access and visibility
  • 07The migration account cannot see or touch anything. How do I fix that in one pass?
  • 08How do I onboard an account across every project, or a thousand people as customers?
  • 09I wrote a value to a field and the user sees nothing. Where do I look?
Identity, cleanup and proof
  • 10How do I suspend a whole domain of accounts after a migration?
  • 11How do I clean up the empty Teams a departing admin left behind?
  • 12Migrated users exist and are licensed, but nobody can find them in a picker. Why?
  • 13How do I prove an admin change actually did what I intended?
The thirteen tools, one at a time
  • 14set_read_only_dc and set_read_only_cloud — how do I run them?
  • 15bulk_assign_permission_scheme — how do I run it?
  • 16copy_permission_schemes_dc_to_cloud — how do I run it?
  • 17add_group_to_permission_schemes and add_group_to_security_levels — how do I run them?
  • 18add_user_to_all_project_roles — how do I run it?
  • 19suspend_accounts_by_domain — how do I run it?
  • 20bulk_add_group_customers — how do I run it?
  • 21remove_solo_member_teams — how do I run it?
  • 22fetch_screen_fields, diagnose_user_picker and delete_stale_projects — how do I run them?
When it goes wrong
  • 23I applied the read-only scheme and now nobody can see anything.
  • 24revert-from-dc-backup reports unmappedScheme. Is that a failure?
  • 25My audit says there are fewer issues than Data Center. Where did they go?
  • 26The org admin API returns 401, 403, or a suspiciously short list.
  • 27Rate limits on a site-wide run.
  • 28How do I prove an admin change did what I intended?

01What is the first command I should run?

The export. Snapshot every project's current permission scheme before anything in this repository touches the site — that file is your undo for most of what follows.

Take the snapshot

git clone https://github.com/leanzero-srl/leanzero-jira-admin-toolkit.git
cd leanzero-jira-admin-toolkit/bulk_assign_permission_scheme
npm install
cp .env.example .env      # CLOUD_BASE_URL, CLOUD_API_TOKEN = base64("email:api_token")

node main/bulk_assign_permission_scheme.js export
CarefulTake it before you need it. Every tool here writes a snapshot of what it is about to change, but only of what it changes. The site-wide export is the only artefact that captures the whole starting state, and the only moment you can take it is before the first write.

Every tool is a plain Node.js CLI over the public REST API, plus the Atlassian admin and Teams APIs for the org-level tools. Node 18 or newer, a Cloud API token, and — for suspend_accounts_by_domain, remove_solo_member_teams and diagnose_user_picker — an organisation admin API key minted at admin.atlassian.com → Settings → API keys.

02Why does this exist at all — can't the admin UI do this?

No. Jira Cloud has no bulk endpoints for most administration, and the admin UI has no multi-select. Assigning a scheme to 300 projects is 300 individual PUTs.

In Jira Data Center this class of work was three Groovy console scripts using ComponentAccessor.getPermissionSchemeManager(). That API does not exist in Cloud. Cloud exposes REST endpoints only, and there is no bulk variant: every project is mutated individually via PUT /rest/api/3/project/{keyOrId}/permissionscheme.

The scale of it

JobCallsIn the UI
Assign one permission scheme to 300 projects300 PUTs300 page loads, no multi-select
Add a group to every permission in 40 schemes1,000+ POSTsOpen every scheme, every permission row
Add a group to every security level in every schemeone per levelSame, per level
Add one user to every role on every projectone per role per projectSame, per role

So the work either does not get done, or it gets done by hand over three days with mistakes in it. These tools do it in one pass, with a dry run, a snapshot, and a documented way back.

03This is someone else's production site. What is the rule?

Ask before you change configuration, every time. Reversibility is not authorisation — a perfectly reversible change nobody sanctioned is still a change nobody sanctioned.

CarefulReversible is not the same as authorised. The damage from an unsanctioned configuration change is to trust, not to data, and it is not undone by undoing the change. On a client's production site, ask the system owner, get the yes, then act — even when a plan was approved earlier, because a yes to a plan is not a yes to each write inside it.

Always fine, without asking

  • Reading anything
  • Dry runs, plans and exports
  • Analysis, reports and diagnostics
  • Anything at all in a sandbox

The line is between reading and changing, not between small and big. Every tool here defaults to read-only for exactly that reason: the plan is the artefact you put in front of the system owner, with the exact numbers, so the question is answerable in one line.

CarefulAnd prove the negative before you act on it. A count of zero, an empty list, a 404 — none of those licence a write until you have proved your credentials can see the thing at all, on that same object. A positive control on a different project proves nothing, because permission and security filters are per object. "The project is empty" and "my account cannot see this project's issues" look identical from outside.

04How do I stop people editing Data Center during the cutover?

Put every project on one read-only permission scheme, with the before state of each project recorded so the freeze can be lifted exactly.

The most damaging thing that can happen during a cutover is a user editing an issue on DC after the export snapshot was taken. That edit is invisible: the Cloud copy is simply stale, and nobody finds out until someone notices a missing comment months later.

Freeze and thaw

cd set_read_only_dc
npm install

# Create the read-only scheme in the DC admin UI first; note its numeric id.
node set_permission_scheme_dc.js set --scheme-id 10500 --dry-run
node set_permission_scheme_dc.js set --scheme-id 10500     # KEEP the printed backup path

# ... run the migration ...

node set_permission_scheme_dc.js revert --backup dc_lockdown_<timestamp>.json

Auth — pick one style, flag or environment

--pat / JIRA_DC_PAT
Bearer, Personal Access Token. Preferred on modern DC.
--basic-auth / JIRA_DC_BASIC_AUTH
Basic, pre-encoded user:password.
--user --password / JIRA_DC_USER, JIRA_DC_PASSWORD
Basic, plain credentials.
TipThe backup file stores both the scheme id and its name. The id is what revert uses on DC. The name is what makes it useful on the other side of the migration, where ids mean nothing and names usually survive — which is exactly what the next section uses.

05After migration, how do I restore each project's original scheme on Cloud?

revert-from-dc-backup reads the DC backup and matches by scheme name. Anything with no Cloud equivalent is reported, and backfill-missing-schemes can build those from the source DC.

set_read_only_cloud modes

ModeWhat it does
setAssign one permission scheme to all or filtered projects, saving the before state.
revertPut projects back using a snapshot this tool produced.
lockdownCreate a brand-new read-only scheme on Cloud and assign it in one shot. Output is revert-compatible.
revert-from-dc-backupRead the DC backup and re-apply each project's original scheme on Cloud, matching by name because ids changed across instances.
backfill-missing-schemesFor DC scheme names with no Cloud equivalent: fetch the exact definition with grants from the source DC, translate the holders, and create the scheme on Cloud.
NoteA non-zero unmappedScheme count after revert-from-dc-backup is expected, not a failure. Those are DC schemes whose names do not exist on the Cloud tenant — usually because they were never assigned to a project, so the migration assistant never carried them. backfill-missing-schemes and copy_permission_schemes_dc_to_cloud are the two ways to close that gap.

06Why are some of my permission schemes simply not on Cloud?

Migration assistants only carry permission schemes that are assigned to a project. Inactive ones are silently left behind.

copy_permission_schemes_dc_to_cloud rebuilds them as literal copies, translating every grant's holder to the target instance. It is creation only — it deliberately assigns nothing to any project, so it cannot change what anybody can do today.

Dry run, then create

node copy_permission_schemes.js            # DRY RUN: resolve + report, no writes
node copy_permission_schemes.js --apply   # create the schemes
TipSeparating creation from assignment is the whole point. You can run this at any time, including well before cutover, and inspect the result at leisure — because nothing is attached to anything until a human attaches it.

07The migration account cannot see or touch anything. How do I fix that in one pass?

add_group_to_permission_schemes widens every permission that already has a grant; add_group_to_security_levels does the same for issue security.

add_group_to_permission_schemes iterates every scheme, reads the existing grants, collects the distinct set of permission types that already have at least one grant, and ensures the target group is present in each. Permission types with no grants at all are left alone — the tool widens what already exists, it does not invent policy.

Flags, both tools

FlagDefaultMeaning
--dry-runoffReport only, send no writes.
--group <name>org-adminsThe group to add.
--scheme <name>allOnly schemes matching this string, case-insensitive.
Careful`add_group_to_security_levels` widens visibility. On a production site, security levels usually exist for a reason. Scope it with --scheme, ask first, and remove the group again when the migration work is finished — this is a temporary measure for an audit window, not a configuration change.
NoteThe symptom that sends people here is an audit that under-reports: "the DC report says 40,000 issues and Cloud shows 31,000". Nine times out of ten the 9,000 exist and the auditing account cannot see them.

08How do I onboard an account across every project, or a thousand people as customers?

add_user_to_all_project_roles for roles; bulk_add_group_customers for JSM, with an audit phase that checks group membership in bulk rather than a thousand per-user lookups.

Two different shapes of the same problem

add_user_to_all_project_roles
Adds a user to all roles across all or filtered projects. Paginated, rate-limit-aware, duplicate-safe — a user already in a role is skipped, not errored. For onboarding an admin, a service account or a migration bot.
bulk_add_group_customers
Collects every member of a Jira Cloud group and adds them as JSM customers via POST /rest/servicedeskapi/customer. Two phases: collect and feasibility-gate first, then add.
TipThe audit phase is the interesting part. For a group of a thousand people, checking each one's existing access with a per-user lookup is a thousand round trips. Instead it fetches the membership of the relevant access groups once and set-intersects locally — the same answer, in a few calls.
LimitEmail visibility is a real constraint. Many accounts hide their email address behind profile-visibility settings, and the customer API needs it. resolve_emails.js uses the org admin API to fill those gaps; accounts it still cannot resolve are reported rather than skipped silently.

09I wrote a value to a field and the user sees nothing. Where do I look?

The screen. fetch_screen_fields dumps every tab of a screen and every field on it — the answer is usually that the field is not there.

This is the single most common false positive in any migration: the PUT returns 204, the value is genuinely stored, and the field is not on the project's edit or view screen, so nobody can see it. The API cannot warn you, because nothing went wrong from its point of view.

Dump a screen

cd fetch_screen_fields
npm install
node fetch_screen_fields.js --screen 12722

Output is one text file per tab, listing field names and ids. Diff two screens, or grep for the field you just wrote. Rate-limited with automatic retries, and it accepts an optional project key for permission scoping.

10How do I suspend a whole domain of accounts after a migration?

suspend_accounts_by_domain, via the org admin API. Suspension is reversible, frees the licence and keeps the data — which is why it is the default and removal is not.

The two modes

ModeEndpointEffect
suspend (default)POST /admin/v1/orgs/{orgId}/directory/users/{accountId}/suspend-accessSets account_status=inactive, frees the licence, keeps the data. Recover with restore-access.
removerequires --force-removeRemoves the account. Not reversible. Deliberately awkward to reach.
CarefulDiscovery uses `POST /v1/orgs/{orgId}/users/search`, not `GET /users`. The plain GET returns only managed accounts — those whose email domain the org has claimed. Invited-but-unclaimed users never appear there, and they are usually exactly the population you are trying to suspend. Using the wrong endpoint gives you a short list and a false sense of completeness.

The proving run

  1. 1npm run plan-only -- --limit 5 — confirms auth and lists the first five matches. Inspect the CSV: verify every email ends in the target domain, and that no org admins are in the list.
  2. 2Pick one non-critical account, run it, and confirm account_status=inactive in admin.atlassian.com.
  3. 3Restore that same account via the restore endpoint, to prove the recovery path works before you need it.
  4. 4Only then run the full plan.

11How do I clean up the empty Teams a departing admin left behind?

remove_solo_member_teams finds every Atlassian Team whose single sole member is one given person, and re-checks each team at delete time so a stale plan can never delete the wrong thing.

A team is flagged when it has exactly one member and that member is the target. Matching is by exact display name by default (case-insensitive), which intentionally captures both accounts when a person holds two identities, while never matching a similar name. Set TARGET_ACCOUNT_IDS to switch to precise accountId-only matching.

Two phases

PLAN (default)
Read-only. Lists every team, finds the matches, and writes a reviewable CSV plus a plan JSON. Nothing is changed.
APPLY (--apply)
Deletes the flagged teams. Every team is re-checked at delete time: if it has since gained members or its sole member changed, it is skipped.
NoteThe Teams API returns members as accountIds only, so the tool resolves each sole member's display name through Jira before matching. That is also why the identity check is worth doing on a sample first — a display-name match is only as good as the display names.

12Migrated users exist and are licensed, but nobody can find them in a picker. Why?

Usually an index gap, not a permission problem. diagnose_user_picker proves which it is instead of guessing, by cross-tabulating findability against every org directory attribute.

The tell is a split that permissions cannot produce: GET /user/{accountId} returns the account, and GET /user/search?query=<name> does not. If the account were hidden by permissions or licensing, both would fail. If only search fails, the account is missing from Jira's user-search index.

What it measures, per account

SignalQuestion
findableByNameDoes /rest/api/3/user/search?query=<displayName> return this id?
findableByPickerDoes /rest/api/3/user/picker?query=<displayName> return this id?
org directory attributesclaimStatus, platformRoles, email domain, product count
TipThen it cross-tabulates findable against every attribute, so the discriminator falls out of the data rather than being guessed. If every unfindable account shares one claimStatus and every findable one does not, you have the cause in one table. It is entirely read-only — nothing is written to Jira.
LimitThe usual remedy is not verified by this tool. Removing an account from its licensing group and re-adding it re-triggers indexing, but propagation is slow — test it on one account and give it several minutes before concluding it did not work.

13How do I prove an admin change actually did what I intended?

Log in as the affected user. A permission change is only verifiable from the other side of it — the API tells you the grant exists, not what anybody can now do.

The verification that counts

  1. 1Check one project in the admin UI, not the API response. A scheme assignment that succeeded and pointed at the wrong scheme looks identical in both.
  2. 2Log in as someone affected — a real account in the target group, ideally with the narrowest permissions of the group. Confirm they can do the thing you intended, and nothing more.
  3. 3For anything that widens visibility, verify by absence. Log in as an account that should not see a restricted issue and confirm it cannot. A count of successful writes says nothing about who can now read what.
  4. 4Diff against the snapshot. You took the export before you started; compare it with a fresh one. Anything that changed and should not have is right there.
  5. 5Then remove the temporary widening. Groups added to security levels or permission schemes for a migration window should come back out when the window closes. Put it on the plan, not in someone's memory.
Careful"300 projects updated" is a proxy metric. It is compatible with 300 projects now on a scheme that locks out the people who need them. Assume the change is wrong until an affected user proves otherwise.

14set_read_only_dc and set_read_only_cloud — how do I run them?

The pair that brackets a cutover. Freeze Data Center, migrate, then restore each project's original scheme on Cloud by matching scheme name — because the ids changed.

Freeze and thaw Data Center

cd set_read_only_dc && npm install
# Create the read-only scheme in the DC admin UI first; note its numeric id.

node set_permission_scheme_dc.js --scheme-id 10500 --dry-run
node set_permission_scheme_dc.js --scheme-id 10500 --output-file dc_lockdown.json
node set_permission_scheme_dc.js --input-file dc_lockdown.json    # thaw

set_read_only_dc flags

FlagWhat it does
--scheme-id <id>The read-only scheme to assign.
--output-file <path>Where the before-state backup is written. Keep this path.
--input-file <path>Restore from that backup.
--project-key <KEY> / --project-pattern <regex> / --exclude-pattern <regex>Scope.
--url, and one of --pat / --basic-auth / --user + --passwordConnection and auth. Also readable from JIRA_DC_PAT, JIRA_DC_BASIC_AUTH, JIRA_DC_USER, JIRA_DC_PASSWORD.
--dry-runReport only.

The Cloud side — five modes

cd set_read_only_cloud && npm install

# Create a read-only scheme and assign it in one shot.
node set_permission_scheme.js --scheme-name "Migration Lockdown" \
  --scheme-description "Read-only for cutover" --dry-run

# Restore original schemes on Cloud from the DC backup, matching by NAME.
node set_permission_scheme.js --input-file dc_lockdown.json --dc-url https://jira-dc.example.com

# Recreate the DC schemes that have no Cloud equivalent.
node set_permission_scheme.js --input-file dc_lockdown.json --dc-url … --execute

set_read_only_cloud flags

FlagWhat it does
--scheme-id <id>Assign an existing Cloud scheme.
--scheme-name / --scheme-descriptionCreate a new read-only scheme, then assign it.
--input-file <path> / --output-file <path>Restore from a snapshot, or write one.
--dc-url and --dc-pat / --dc-basic-auth / --dc-user + --dc-passwordReach back into the source DC to fetch scheme definitions for the backfill.
--browse-group / --browse-account-id / --browse-app-roleWho keeps Browse Projects in the read-only scheme. Get this wrong and nobody can see anything.
--admin-group <name>The group that keeps administrative access.
--user-map <path>DC username to Cloud accountId map, for translating grant holders.
--only <names> / --name-prefix <str>Restrict which schemes are created or restored.
--project-key / --project-pattern / --exclude-patternScope.
--auditReport the current assignment for every project and stop.
--dry-run / --executePreview, or write.
CarefulSet `--browse-group` deliberately. A read-only scheme that also removes Browse Projects does not make the instance read-only — it makes it invisible, and every dashboard, filter and integration breaks at once. Grant Browse to everyone who had it, and remove only the write permissions.
Tipdraft_user_map.js generates the starting --user-map by pairing DC usernames with Cloud accounts. Review it before using it — it is a draft, and a wrong pairing hands someone else's permissions to the wrong person.

15bulk_assign_permission_scheme — how do I run it?

Export, apply, restore. Take the export before anything else in this repository touches the site — it is your undo for most of what follows.

The three modes

cd bulk_assign_permission_scheme && npm install && cp .env.example .env

node main/bulk_assign_permission_scheme.js --mode export
node main/bulk_assign_permission_scheme.js --mode apply --scheme-id 10200 --dry-run
node main/bulk_assign_permission_scheme.js --mode apply --scheme-id 10200 --confirm
node main/bulk_assign_permission_scheme.js --mode restore --file snapshot.json --confirm

Flags

FlagWhat it does
--mode export\|apply\|restoreSnapshot, bulk-assign, or roll back.
--scheme-id <id>The target scheme, in apply mode.
--file <path>Snapshot to write or read.
--only-key <regex>Restrict to matching project keys. Anchor it — '^ABC$' for one project.
--include-archivedInclude archived projects, which are skipped by default.
--confirmRequired to write. Without it you get a plan.
--dry-runExplicit preview.
NoteOn HTTP 5xx the client backs off exponentially up to 10s for 3 attempts — except when the body contains Illegal Entity Scope, which is a real Atlassian error meaning non-retriable. That one fails fast rather than burning the retry budget.

16copy_permission_schemes_dc_to_cloud — how do I run it?

Creation only. It recreates the DC schemes a migration assistant left behind, translates every grant holder, and deliberately assigns nothing.

Run it

cd copy_permission_schemes_dc_to_cloud && npm install && cp .env.example .env

node copy_permission_schemes.js                    # DRY RUN: resolve + report
node copy_permission_schemes.js --only "Casino,HR" # scope by scheme name
node copy_permission_schemes.js --apply            # create them

Flags

FlagWhat it does
--applyActually create the schemes. Default is a dry run.
--only <names>Comma-separated scheme names to process.
--limit <n>Cap how many are created.
--include-existingProcess schemes that already exist on Cloud, instead of skipping them.
TipSeparating creation from assignment is the point. Run this any time — well before cutover if you like — and inspect the result at leisure, because nothing is attached to any project until a human attaches it.

17add_group_to_permission_schemes and add_group_to_security_levels — how do I run them?

Two tools, four flags, identical shape. One widens every permission that already has a grant; the other widens every security level.

Both, same pattern

node main/add_to_permission_schemes.js --dry-run
node main/add_to_permission_schemes.js --scheme "Default software scheme"
node main/add_to_permission_schemes.js --group org-admins

node main/add_to_security_levels.js --dry-run
node main/add_to_security_levels.js --scheme "IT Security Scheme"

Flags

FlagDefaultWhat it does
--dry-runoffReport only.
--group <name>org-adminsThe group to add.
--scheme <name>allOnly schemes whose name contains this string, case-insensitive.
NotePermission types with no existing grant are left alone — the tool widens what already exists rather than inventing policy. Runs append to logs/permissions_<epoch>.log and logs/security_<epoch>.log, which are your undo lists.
CarefulSecurity levels exist for a reason. Treat add_group_to_security_levels as a temporary measure for an audit window: scope it with --scheme, ask the system owner first, and remove the group again when the work is done. Put the removal on the plan, not in someone's memory.

18add_user_to_all_project_roles — how do I run it?

Onboard an account across every project in one pass. Paginated, rate-limit-aware, and duplicate-safe — a user already in a role is skipped, not errored.

Run it

cd add_user_to_all_project_roles && npm install

node add_user_to_all_projects.js \
  --url https://your-site.atlassian.net --email you@example.com --token "$TOKEN" \
  --user 712020:00000000-0000-0000-0000-000000000000 --dry-run

# Or add yourself, and only to projects matching a pattern:
node add_user_to_all_projects.js --self --project-pattern '^ABC'

Flags

FlagWhat it does
--user <accountId>Who to add.
--selfAdd the account behind the token, so you do not have to look up your own id.
--project-key <KEY> / --project-pattern <regex>One project, or a pattern.
--url / --email / --tokenConnection. --token-base accepts a pre-encoded email:token.
--dry-runReport only.

19suspend_accounts_by_domain — how do I run it?

Org-admin API, two phases, suspension by default because it is reversible. Prove the recovery path on one account before the bulk run.

The proving run, then the real one

cd suspend_accounts_by_domain && npm install && cp .env.example .env
# ORG_ADMIN_API_KEY from admin.atlassian.com -> Settings -> API keys; ORG_ID from the admin URL

npm run plan-only -- --limit 5      # confirms auth, lists 5 matches, writes a CSV
npm run dry-run                     # full plan, no writes
npm start                           # suspend
npm run resume                      # continue an interrupted run

Flags

FlagDefaultWhat it does
--mode suspend\|removesuspendSuspension frees the licence and keeps the data. Removal is not reversible.
--force-removeoffRequired alongside --mode remove. Deliberately awkward.
--domain <str>—Exact email domain, without the @.
--domain-contains <str> / --needle <str>—Looser matching, for domains with variants.
--file <path>—Drive from a CSV of accountIds instead of a domain — that is suspend_from_csv.js, for multi-domain cases.
--include-already-suspendedoffProcess accounts that are already inactive. Off by default, which saves thousands of no-op calls.
--include-non-atlassianoffInclude accounts outside the Atlassian directory.
--plan-only / --execute-only / --resume / --plan-file / --retry-failed—The standard two-phase controls.
--limit <n> / --concurrency <n> / --page <n>—Cap, throughput, and page size for discovery.
CarefulDiscovery uses `POST /v1/orgs/{orgId}/users/search`, not `GET /users`. The plain GET returns only managed accounts — those whose domain the org has claimed. Invited-but-unclaimed users never appear there, and they are usually exactly the population you are trying to suspend. The wrong endpoint gives you a short list and a false sense of completeness.

Before the bulk run

  1. 1Inspect the plan CSV. Verify every email ends in the target domain, and that no org admins are in the list.
  2. 2Suspend one non-critical account and confirm account_status=inactive in admin.atlassian.com.
  3. 3Restore that same account via POST …/restore-access — prove the recovery path before you need it.
  4. 4Only then run the full plan.

find_migration_accounts.js is the companion for the adjacent question: which org accounts were created by the migration itself, so you can decide what to do with them separately.

20bulk_add_group_customers — how do I run it?

Collect, audit, then add. The audit phase set-intersects group membership locally instead of doing a thousand per-user lookups.

The three phases

cd bulk_add_group_customers && npm install && cp .env.example .env

node collect_members.js --group <groupId>     # collect + feasibility gate
node resolve_emails.js                        # fill emails the profile hides, via org API
node audit_customers.js                       # who already has access
node add_customers.js --send                  # add them as customers

Flags

FlagWhat it does
--group <id> / --name <str>Which group to collect, by id or name.
--servicedesk <key>Which service desk to add customers to — add_servicedesk_customers.js.
--sendActually create the customers. Without it, everything is a report.
--limit <n> / --batch-size <n> / --concurrency <n>Cap and throughput.
--include-inactive / --include-appInclude deactivated accounts, or app accounts. Both off by default.
--resumeContinue an interrupted run.
LimitEmail visibility is the real constraint. Many accounts hide their address behind profile-visibility settings, and the customer API needs it. resolve_emails.js fills those gaps via the org admin API; anything it still cannot resolve is reported rather than silently skipped.

21remove_solo_member_teams — how do I run it?

Plan, review the CSV, apply. Every team is re-checked at delete time, so a stale plan can never delete the wrong thing.

Run it

cd remove_solo_member_teams && npm install && cp .env.example .env

node main/remove_solo_member_teams.js --plan-only      # read-only; writes CSV + plan JSON
node main/remove_solo_member_teams.js --apply --limit 1
node main/remove_solo_member_teams.js --apply

Flags

FlagWhat it does
--target-name <str>Match the sole member by exact display name, case-insensitive. Captures both accounts when a person holds two identities.
--target-account-ids <ids>Switch to precise accountId-only matching. Prefer this when display names are ambiguous.
--org-id <uuid> / --site-url <url>Override .env.
--plan-only / --execute-only / --resume / --plan-fileTwo-phase controls.
--applyDelete the flagged teams.
--limit <n> / --concurrency <n>Cap and throughput.
NoteThe Teams API returns members as accountIds only, so the tool resolves each sole member's display name through Jira before matching. That is why a display-name match is worth sanity-checking on a sample — it is only as good as the display names.

22fetch_screen_fields, diagnose_user_picker and delete_stale_projects — how do I run them?

Two read-only diagnostics and one destructive housekeeping tool. The first answers the most common false positive in the whole repository set.

Screen fields — read-only

cd fetch_screen_fields && npm install

node fetch_screen_fields.js --screen 12722 \
  --url https://your-site.atlassian.net --email you@example.com --token "$TOKEN"

# Optional: scope permissions to one project
node fetch_screen_fields.js --screen 12722 --project-key ABC

Output is one text file per screen tab, listing field names and ids. Diff two screens, or grep for the field you just wrote. This is where "I wrote a value and the user sees nothing" is usually answered: the field is not on the screen, the write succeeded, and no API could have told you.

User-picker diagnosis — read-only, writes nothing to Jira

cd diagnose_user_picker && npm install
# Reuses credentials from ../sync_same_instance_fields/.env and
# ../suspend_accounts_by_domain/.env — both must exist.

# Put the accountIds to test in cohort.json, then:
node diagnose.js

It cross-tabulates findability by name and by picker against claimStatus, platformRoles, email domain and product count, so the discriminator falls out of the data instead of being guessed. The usual answer is a user-search index gap — the account is returned by accountId and not by search, which permissions cannot cause.

Stale projects — sandbox only

cd delete_stale_projects && npm install

node delete_old_projects.js --url https://your-sandbox.atlassian.net \
  --email you@example.com --token "$TOKEN" --months 18 --dry-run

delete_stale_projects flags

FlagWhat it does
--months <n>Delete projects not updated in this many months.
--dry-runReport only. Always start here.
--forceSkip the interactive confirmation. Do not use this.
--url / --email / --tokenConnection.
CarefulThis tool validates that the target URL looks like a sandbox and requires an interactive confirmation. Both guards exist because project deletion is not reversible. If you find yourself reaching for --force, stop.

23I applied the read-only scheme and now nobody can see anything.

Browse Projects was removed along with the write permissions. Read-only means writes are blocked, not that the instance disappears.

Recovering

  1. 1Restore immediately from the snapshot: --input-file <the file you saved>. That is why the export comes first.
  2. 2Rebuild the scheme with Browse Projects granted — --browse-group, --browse-account-id or --browse-app-role, depending on who needs it.
  3. 3Test on one project before re-applying site-wide, and log in as an ordinary user to confirm they can still see it.
CarefulEverything downstream of Browse breaks at once — dashboards, filters, boards, integrations, JSM portals. It is the single most disruptive mistake available in this repository, and it is entirely avoidable by setting the browse flags deliberately.

24revert-from-dc-backup reports unmappedScheme. Is that a failure?

No — it is expected. Those are DC schemes whose names do not exist on Cloud, usually because they were never assigned to a project and the migration assistant never carried them.

Closing the gap

OptionWhen to use it
backfill-missing-schemes in set_read_only_cloudFetch the exact definition with grants from the source DC, translate the holders, and create it on Cloud. Needs --dc-url and DC credentials.
copy_permission_schemes_dc_to_cloudThe same job as a standalone run, with --only to scope by name. Creation only — it assigns nothing.
Leave itIf the scheme was unassigned on DC and nothing needs it on Cloud, not recreating it is a legitimate answer. Record the decision.
NoteMatching is by scheme name, because ids do not survive an instance boundary. If someone renamed a scheme during the migration, that is why it appears unmapped — rename it back, or supply the mapping.

25My audit says there are fewer issues than Data Center. Where did they go?

Usually nowhere. The auditing account cannot see them, and an invisible issue and a missing issue produce identical output.

Prove it before you act on it

  1. 1Pick one issue you know exists on both sides, in a project reporting a shortfall.
  2. 2Fetch it by key with the audit's own credentials, on both instances. If either fetch fails, the shortfall is visibility, not loss.
  3. 3The usual cause is an issue security level the account is not a member of — invisible in every direction, no error, just a smaller number.
  4. 4Run add_group_to_security_levels --scheme <name> for the affected schemes, re-run the audit, and remove the group again afterwards.
CarefulA count of zero, an empty list or a 404 does not licence a write until you have proved your credentials can see the object at all, on that same object. Permission and security filters are per object, so a positive control on a different project proves nothing.

26The org admin API returns 401, 403, or a suspiciously short list.

Three different problems. The short list is the dangerous one, because it looks like success.

Symptom to cause

SymptomCauseFix
401 on every org callThe API key is wrong, or you are sending it as Basic instead of Bearer.Org keys are Authorization: Bearer <key>, not email:token. Mint one at admin.atlassian.com → Settings → API keys.
403 on the orgThe key's creator is not an org admin. Keys inherit the creator's permissions.Have an org admin mint it.
404 on /admin/v2/*The org is on the legacy user-management experience.Use the v1 paths — that is why these tools target v1.
Discovery returns far fewer users than expectedGET /users was used, which only returns managed accounts.The tools use POST /v1/orgs/{orgId}/users/search, which covers invited-but-unclaimed accounts too. If you are scripting this yourself, do the same.
Teams API returns members with no namesIt returns accountIds only, by design.Resolve display names through Jira, as remove_solo_member_teams does.

27Rate limits on a site-wide run.

Expected — there are no bulk endpoints, so a site-wide change is thousands of individual calls. Lower concurrency and let it take the time it takes.

What to change

SymptomChange
Sporadic 429Drop --concurrency to 2–3. Backoff is exponential but sustained over-concurrency outruns it.
5xx with Illegal Entity ScopeNot retriable — that is a real Atlassian error, and the client fails fast rather than burning retries. Fix the scope.
One project fails, the run continuesCorrect behaviour. Per-item failure tracking means one bad project cannot abort a run of three hundred; read the failures at the end.
Run interrupted halfway--resume. The snapshot and plan mean nothing is lost, and already-applied projects are skipped.

28How do I prove an admin change did what I intended?

Log in as someone affected. A permission change is only verifiable from the other side of it — the API tells you the grant exists, not what anybody can now do.

The verification that counts

  1. 1Check one project in the admin UI, not the API response. An assignment that succeeded and pointed at the wrong scheme looks identical in both.
  2. 2Log in as a real affected user — ideally the one with the narrowest permissions in the group. Confirm they can do what you intended, and nothing more.
  3. 3For anything that widens visibility, verify by absence. Log in as someone who should not see a restricted issue and confirm they cannot.
  4. 4Diff against the snapshot. Take a fresh export and compare it with the one from before. Anything that changed and should not have is right there.
  5. 5Remove the temporary widening. Groups added to security levels or permission schemes for a migration window come back out when it closes.
Careful"300 projects updated" is a proxy metric. It is compatible with 300 projects now on a scheme that locks out the people who need them. Assume the change is wrong until an affected user proves otherwise.

What it does, and what it will not do

Stated up front, because discovering a limit mid-cutover is the expensive way to find it.

What it does

  • A JSON snapshot of the previous state before every assignment change, with a revert mode that consumes it
  • Permission schemes recreated on Cloud with every grant holder translated — including the unassigned ones migration drops
  • A portable DC backup that can restore original schemes on Cloud by name, after the ids have changed
  • Reversible suspension as the default for account removal, with the recovery path proved before the bulk run
  • Re-verification at write time, so a stale plan can never act on state that has changed
  • Exponential backoff on 429 and 5xx, with per-item failure tracking so one bad project cannot abort a run of three hundred

What it will not do

  • It will not decide for you whether a production configuration change is authorised — that conversation is yours to have with the system owner
  • It will not invent policy: permission types with no existing grant are left alone, not populated
  • It will not create issue security levels or schemes; widening visibility is temporary by design and you remove it afterwards
  • It will not remove accounts without an explicit --force-remove, because removal is not reversible and suspension is
  • It will not fix a user-search index gap; the diagnostic proves what the cause is, the remedy is an admin action
  • It will not tell you a permission change worked. Only logging in as an affected user can
Apache-2.0 licensed. Free to use, fork and ship inside your own migration.

Administering Jira Cloud at migration scale?

The toolkit is free and Apache-2.0. If you would rather have someone run the plan, put the numbers in front of your stakeholders, and execute the window with you, that is what we do.

View on GitHubAtlassian migrationsAsk on Discord