Confluence Space Permissions After Migration: Audit Who Lost or Gained Access
Mihai Perdum
Author
16 min readSeptember 23, 2026
Key takeaways
Access breaks two ways in a Confluence migration. If you migrate without users and groups, grants to groups that do not exist in Cloud are not migrated, so people lose spaces and pages. Groups link by name and merge, so other people gain the Cloud group's permissions.
A re-migration only adds group members. It never removes one, so a membership you cleaned up on Data Center stays in Cloud.
The audit script in this tutorial snapshots every space permission, role assignment, page restriction and group member through the Cloud REST API, then prints two lists: what DISAPPEARED and what APPEARED. It keys on names, because content IDs are not preserved in a Cloud-to-Cloud transfer.
Run on our test site, the diff caught both simulated side effects: a group losing read/space and export_content/space, and a user appearing in a group. Deleting a group's view permission also removed its create/page and update/page, which no doc we found mentions.
Atlassian's roles page contradicts itself about a destination in Roles only mode. Record the mode with GET /wiki/api/v2/space-role-mode before and after, rather than trusting either sentence.
Confluence space permissions after migration break two ways: some people lose access to spaces and private pages, and others quietly gain it when groups merge by name. The first shows up the next morning as a support ticket. The second never shows up at all, which is why it is the one to worry about.
This tutorial walks through a before-and-after permission audit you can run on your own migration, whether it is Data Center to Cloud through the Confluence Cloud Migration Assistant (CCMA) or a Cloud-to-Cloud transfer. By the end you will have a snapshot of every space permission, role assignment, page restriction and group member on the destination, and a diff that prints two lists: access that disappeared, and access that appeared.
Note
Prerequisites — A Confluence Cloud destination site and an API token for an account that is a Confluence admin there (the script reads permissions, restrictions and group members). Node.js 18 or later, since the script uses the built-in fetch and has no dependencies. For the Data Center half, read access to the Confluence database. The Cloud half was run on our test site on 23 September 2026. The Data Center queries are quoted from Atlassian's knowledge base and were not run here, because we have no Data Center instance.
How access breaks in both directions
Atlassian documents both failure directions, but on different pages, so it helps to see them side by side.
Access is lost when you migrate without users and groups and a grant points at a group that does not exist in Cloud. From How Confluence users and groups are migrated: "if you choose not to migrate users and groups and you have a space permission granted by a group that doesn't exist in cloud, the Confluence Cloud Migration Assistant will not migrate the respective space permission." Their fix is to create the group in the cloud site before migrating.
Access is gained when names collide. The same page: "Group linking is identified by group name and could result in permission escalation during migration." The CCMA documentation spells out the direction: "we will merge the users from the server group into the cloud group. The server group users will inherit the permissions of the cloud group."
It does not undo itself on a second run either. From Understand problems with duplicate or re-migrated groups: "only new members will be added to the cloud group. We won't change any existing memberships, for example remove users."
So a single number, "permissions migrated", tells you nothing. You need two numbers: grants that are missing, and grants that are new.
Group names merge: how Confluence users and groups are migrated
Before anything moves, compare group names. Every Data Center group whose name already exists on the destination, in Confluence or in Jira, is a merge. The CCMA documentation says this "also applies to groups with Jira product access that have the same name as a Confluence group you are migrating."
Four groups are never migrated at all. The CCMA page calls them blacklisted: "They will not be migrated at all: "site-admins", "system-administrators", "atlassian-addons", "atlassian-addons-admin"." The users in them still migrate, but their membership does not. Any space grant or page restriction that relied on one of those groups needs a replacement in Cloud.
For a Cloud-to-Cloud transfer the choice is yours at transfer time. The Transfer Confluence data page warns, under the option that copies users and groups separately: "We'll merge groups with the same names. Existing users in the destination may get unwanted permissions." Under the option that copies users with their groups, it says "Group membership will be preserved". Same-name groups still matter under that option: the transfer's checks look for "groups with the same names in your source and destination when you select the option to copy users with their groups". Run the name comparison either way.
Private pages gone after migration: restrictions pinned to missing users and groups
Page restrictions do migrate: the CCMA "what migrates" page lists pages "including page restrictions, page classification level, page history, labels, tags, comments, and shared drafts". The trouble is what the restriction points at. A restriction names users and groups, and if the relationship between users and their groups did not survive, the page is still restricted, just to nobody useful.
Atlassian's Resolve missing spaces and pages in Confluence Cloud after migration names the cause directly: "The relationship between the users and their assigned groups is not preserved during the migration." Its symptom list includes spaces "not being visible to administrators with full site permissions".
In our own test, when a group lost its space view permission, the page restriction naming that group was still there afterwards. The restriction row survives while the space grant underneath it is gone. I did not log in as a group member to test what they could then see, so treat that as a record of the data, not of the effective access.
Roles when migrating Confluence data: the Pre roles reset
Confluence Cloud has a space access mode for roles: Pre roles, Roles transition, or Roles only. A migration can change it. You can read it with one call:
You should see a single mode field; that is our test site's. Now, what happens to it during a Data Center migration? Atlassian's What to expect from roles when migrating Confluence data says, in prose: "If the destination Confluence Cloud instance was in Pre roles or Roles only mode before the migration, the mode after migration will be reset to Pre roles." And, a few lines later: "If the destination Cloud instance was in Roles transition or Roles only mode before the migration, the mode after migration will remain in Roles transition mode."
Roles only appears in both sentences. The table on the same page repeats the contradiction instead of settling it: its first Data Center row puts Pre roles and Roles only in the same before cell with Pre roles after, and its third row lists Roles only again with Roles transition after. The page contradicts itself, and I am not going to pick a side for you. What is unambiguous: Pre roles stays Pre roles, and Roles transition stays Roles transition. For a Cloud-to-Cloud transfer the same page says "If the source and destination tenant modes match, there will be no change in access mode due to the migration."
The practical answer is to record the mode before and after, which the script below does for you.
Audit who can view and edit every page, before and after
This is the procedure. The script takes a snapshot of the destination, you migrate, it takes another, and it diffs them. Everything is compared by name, not by ID, because IDs change: the transfer page says "Content IDs are not preserved during cloud-to-cloud data transfers."
1
Save the audit script
copy the script below into conf-perm-audit.mjs. It has no dependencies.
2
Export your credentials
set CONF_SITE, CONF_EMAIL and CONF_TOKEN for an admin account on the destination site.
3
Take the before snapshot
run node conf-perm-audit.mjs snapshot before.json on the destination before you migrate. Pass a comma-separated list of space keys to limit it. It writes before.json and before.csv.
4
Export the Data Center side
run the SQL queries in the next section against the source database and keep the results with the snapshot.
5
Record the roles mode
note the roleMode the snapshot printed. It is your evidence if the mode changes.
6
Migrate
run CCMA or the Cloud-to-Cloud transfer, with the users-and-groups option you chose deliberately.
7
Take the after snapshot
run node conf-perm-audit.mjs snapshot after.json with the same space keys.
8
Diff the two
first verify the diff itself: node conf-perm-audit.mjs diff before.json before.json should print 0 in both lists and exit 0. Then run node conf-perm-audit.mjs diff before.json after.json. Read both lists. Every APPEARED space-permission, role or group-member line is someone who can now see or do something they could not before.
9
Resolve each line
for a missing grant, re-create it, or use Recover Permissions if an admin has lost the space entirely. For a new grant or member, decide whether it was intended. If not, remove it.
Warning
Page restrictions read the other way. A restriction is an allow list, so if every restriction line for a page is in DISAPPEARED, the page is no longer restricted and everyone with space access can now read it. Treat a page whose restriction lines all disappeared as new access, not lost access.
Here is the script:
js
1#!/usr/bin/env node2// Confluence Cloud permission snapshot + diff. Node 18+, no dependencies.3// Usage:4// CONF_SITE=https://your-site.atlassian.net CONF_EMAIL=you@x.com CONF_TOKEN=... \5// node conf-perm-audit.mjs snapshot out.json [SPACEKEY,SPACEKEY...]6// node conf-perm-audit.mjs diff before.json after.json7import{ writeFileSync, readFileSync }from'node:fs';89const[,, cmd,...args]= process.argv;10constSITE=(process.env.CONF_SITE||'').replace(/\/$/,'');11constAUTH='Basic '+Buffer.from(`${process.env.CONF_EMAIL}:${process.env.CONF_TOKEN}`).toString('base64');1213asyncfunctionget(path){14for(let attempt =0;; attempt++){15const r =awaitfetch(SITE+ path,{headers:{Authorization:AUTH,Accept:'application/json'}});16if(r.status===429&& attempt <5){awaitnewPromise(s=>setTimeout(s,2000*(attempt +1)));continue;}17if(!r.ok)thrownewError(`${r.status}${path}: ${(await r.text()).slice(0,200)}`);18return r.json();19}20}21// v2 cursor pagination: follow _links.next until absent22asyncfunctionall(path){23const out =[];24let next = path;25while(next){26const j =awaitget(next);27 out.push(...(j.results||[]));28 next = j._links?.next ?'/wiki'+ j._links.next.replace(/^\/wiki/,''):null;29}30return out;31}32// v1 offset pagination33asyncfunctionallV1(path){34const out =[];35for(let start =0;;){36const sep = path.includes('?')?'&':'?';37const j =awaitget(`${path}${sep}start=${start}&limit=200`);38 out.push(...(j.results||[]));39if(!j._links?.next ||!(j.results||[]).length)return out;40 start += j.results.length;41}42}4344const userCache =newMap(), groupCache =newMap();45asyncfunctionuserName(id){46if(!userCache.has(id)){47try{const u =awaitget(`/wiki/rest/api/user?accountId=${encodeURIComponent(id)}`); userCache.set(id,{name: u.displayName|| u.publicName|| id,accountType: u.accountType});}48catch{ userCache.set(id,{name:`UNRESOLVED:${id}`,accountType:'unknown'});}49}50return userCache.get(id);51}52asyncfunctiongroupName(id){53if(!groupCache.has(id)){54try{const g =awaitget(`/wiki/rest/api/group/by-id?id=${encodeURIComponent(id)}`); groupCache.set(id, g.name);}55catch{ groupCache.set(id,`UNRESOLVED:${id}`);}56}57return groupCache.get(id);58}59asyncfunctionprincipal(type, id){60const t =String(type ||'').toLowerCase();61if(t ==='user'){const u =awaituserName(id);return{type: u.accountType==='app'?'app':'user', id,name: u.name};}62if(t ==='group')return{type:'group', id,name:awaitgroupName(id)};63return{type: t,id: id ??null,name: t };// e.g. anonymous, access_class64}6566asyncfunctionsnapshot(outFile, keysArg){67const wanted = keysArg ?newSet(keysArg.split(',')):null;68const spaces =(awaitall('/wiki/api/v2/spaces?limit=250')).filter(s=>!wanted || wanted.has(s.key));69const snap ={site:SITE,takenAt:newDate().toISOString(),roleMode:null,70spacePermissions:[],roleAssignments:[],pageRestrictions:[],groupMembers:{}};71try{ snap.roleMode=(awaitget('/wiki/api/v2/space-role-mode')).mode;}catch(e){ snap.roleMode=`unavailable: ${e.message.slice(0,60)}`;}72const roles =newMap();73try{for(const r ofawaitall('/wiki/api/v2/space-roles?limit=100')) roles.set(r.id, r.name);}catch{}74const groupsSeen =newMap();75constnote=p=>{if(p.type==='group') groupsSeen.set(p.id, p.name);return p;};7677for(const s of spaces){78for(const g ofawaitall(`/wiki/api/v2/spaces/${s.id}/permissions?limit=250`)){79const p =note(awaitprincipal(g.principal?.type, g.principal?.id));80 snap.spacePermissions.push({space: s.key,principal: p,operation:`${g.operation.key}/${g.operation.targetType}`});81}82try{83for(const ra ofawaitall(`/wiki/api/v2/spaces/${s.id}/role-assignments?limit=250`)){84const p =note(awaitprincipal(ra.principal?.principalType, ra.principal?.principalId));85 snap.roleAssignments.push({space: s.key,principal: p,role: roles.get(ra.roleId)|| ra.roleId});86}87}catch{}// not every site exposes roles88for(const pg ofawaitall(`/wiki/api/v2/spaces/${s.id}/pages?limit=250`)){89// v1 /restriction returns results[] = [{operation:'read'|'update', restrictions:{user:{results}, group:{results}}}]90const r =awaitget(`/wiki/rest/api/content/${pg.id}/restriction?expand=restrictions.user,restrictions.group`);91for(const op of r.results||[]){92for(const u of op.restrictions?.user?.results ||[]) snap.pageRestrictions.push({space: s.key,pageId: pg.id,title: pg.title,operation: op.operation,93principal:{type:'user',id: u.accountId,name: u.displayName|| u.publicName}});94for(const g of op.restrictions?.group?.results ||[]) snap.pageRestrictions.push({space: s.key,pageId: pg.id,title: pg.title,operation: op.operation,95principal:note({type:'group',id: g.id,name: g.name})});96}97}98}99for(const[id, name]of groupsSeen){100try{101const m =awaitallV1(`/wiki/rest/api/group/${encodeURIComponent(id)}/membersByGroupId`);102 snap.groupMembers[name]= m.map(u=>({id: u.accountId,name: u.displayName|| u.publicName})).sort((a, b)=> a.id.localeCompare(b.id));103}catch(e){ snap.groupMembers[name]=`unavailable: ${e.message.slice(0,80)}`;}104}105writeFileSync(outFile,JSON.stringify(snap,null,2));106const csv =['kind,space,page,operation,principal_type,principal_name,principal_id',107...snap.spacePermissions.map(x=>['space-permission', x.space,'', x.operation, x.principal.type, x.principal.name, x.principal.id]),108...snap.roleAssignments.map(x=>['role', x.space,'', x.role, x.principal.type, x.principal.name, x.principal.id]),109...snap.pageRestrictions.map(x=>['page-restriction', x.space,`${x.pageId}${x.title}`, x.operation, x.principal.type, x.principal.name, x.principal.id]),110...Object.entries(snap.groupMembers).flatMap(([g, ms])=>Array.isArray(ms)? ms.map(m=>['group-member','','', g,'user', m.name, m.id]):[])111].map(r=>Array.isArray(r)? r.map(v=>`"${String(v ??'').replace(/"/g,'""')}"`).join(','): r).join('\n');112writeFileSync(outFile.replace(/\.json$/,'')+'.csv', csv +'\n');113console.log(`snapshot ${outFile}: roleMode=${snap.roleMode} spaces=${spaces.length} spacePermissions=${snap.spacePermissions.length} roleAssignments=${snap.roleAssignments.length} pageRestrictions=${snap.pageRestrictions.length} groups=${Object.keys(snap.groupMembers).length}`);114}115116// Keys compare by NAME, not id: after a migration ids change, names are what link.117functionkeys(s){118const k =newSet();119// Two principals with the same display name (e.g. two app users both called "CogniRunner") would collapse; disambiguate by id.120const byName ={};121for(const x of[...s.spacePermissions,...s.roleAssignments,...s.pageRestrictions])(byName[x.principal.type+':'+ x.principal.name]??=newSet()).add(x.principal.id);122for(const ms ofObject.values(s.groupMembers))if(Array.isArray(ms))for(const m of ms)(byName['user:'+ m.name]??=newSet()).add(m.id);123constwho=p=> byName[p.type+':'+ p.name]?.size >1?`${p.type}:${p.name}#${p.id}`:`${p.type}:${p.name}`;124for(const x of s.spacePermissions) k.add(`space-permission | ${x.space} | ${who(x.principal)} | ${x.operation}`);125for(const x of s.roleAssignments) k.add(`role | ${x.space} | ${who(x.principal)} | ${x.role}`);126for(const x of s.pageRestrictions) k.add(`page-restriction | ${x.space} | "${x.title}" | ${x.operation} | ${who(x.principal)}`);127for(const[g, ms]ofObject.entries(s.groupMembers))if(Array.isArray(ms))for(const m of ms) k.add(`group-member | ${g} | ${who({type:'user',...m })}`);128return k;129}130functiondiff(a, b){131constA=JSON.parse(readFileSync(a)),B=JSON.parse(readFileSync(b));132const ka =keys(A), kb =keys(B);133const gone =[...ka].filter(x=>!kb.has(x)).sort(), added =[...kb].filter(x=>!ka.has(x)).sort();134console.log(`roleMode: ${A.roleMode} -> ${B.roleMode}${A.roleMode!==B.roleMode?' <-- CHANGED':''}`);135console.log(`\nDISAPPEARED (${gone.length}) - access someone had before and no longer has:`);136 gone.forEach(x=>console.log(' - '+ x));137console.log(`\nAPPEARED (${added.length}) - access nobody had before:`);138 added.forEach(x=>console.log(' + '+ x));139 process.exitCode= gone.length|| added.length?1:0;140}141142if(cmd ==='snapshot'&& args[0])awaitsnapshot(args[0], args[1]);143elseif(cmd ==='diff'&& args[1])diff(args[0], args[1]);144else{console.error('usage: snapshot <out.json> [KEY,KEY] | diff <before.json> <after.json>'); process.exit(2);}
What the diff looks like
To show it working without a real migration, I created a test space on our Cloud test site with a restricted page and a group, lz-tutorial-finance, that had view access. I took a snapshot, then reproduced the two documented side effects by hand: I added a user to the group, which is what a same-name merge does, and removed the group's view permission on the space, which is what a dropped grant looks like.
Both side effects are there, one in each list. The group has no create/page or update/page lines because I had already removed and re-added its view grant once before this snapshot, and the removal took those two with it (see below). As a control, diffing a snapshot against itself printed zero in both lists and exited 0. The exit code means you can gate a migration runbook on it.
On the whole test site, 35 spaces, the snapshot collected 14,062 space-permission rows, 335 role assignments and 3 page restrictions in 57.6 seconds.
Things the API taught me while building it
Deleting a group's view permission on a space also removed that group's create/page and update/page. I observed it on the test space and found no documentation that states it. If a grant goes missing after a migration, expect the grants that depend on it to go with it.
A new space grants every installed app user a full set of permissions. On our test space that was 378 of the 450 rows, 27 per app. Filter on principal_type in the CSV if you only care about people.
Display names are not unique. Our site has two app users called CogniRunner and two accounts with the same person's name. The script appends the account ID whenever one name maps to more than one ID, so the diff does not merge them.
The first version of the script reported zero page restrictions on a page I had just restricted. The /restriction/byOperation endpoint returned no restriction data. The plain /restriction endpoint with expand=restrictions.user,restrictions.group returns them, and that is what the script uses. A zero from an audit is only a finding once you have seen it count something, the same lesson as a Jira migration audit that counted a missing field as zero.
The Data Center side: export before you migrate
On Data Center, the same inventory comes from the database. These queries are from Atlassian's knowledge base, quoted as published. I have not run them, because we have no Data Center instance.
1SELECT sp.permid, sp.permtype, s.spacekey, s.spacename, sp.permgroupname, um.lower_username FROM SPACEPERMISSIONS sp JOIN SPACES s ON sp.spaceid = s.spaceid LEFTJOIN user_mapping um ON sp.permusername = um.user_key WHERE s.spacekey ='<SPACEKEY>';
Page restrictions across all spaces, from How to List Pages with Restrictions. The article warns "Run this query at your own risk" and says to test on staging first:
sql
1SELECT c.CONTENTID, c.TITLE, s.SPACEKEY, s.SPACENAME, cps.CONT_PERM_TYPE, map.username FROM SPACES s JOIN CONTENT c ON s.SPACEID = c.SPACEID JOIN CONTENT_PERM_SET cps ON c.CONTENTID = cps.CONTENT_ID JOIN CONTENT_PERM cp ON cps.ID = cp.CPS_ID JOIN user_mapping map ON cp.USERNAME=map.user_key;
Verify the export before you rely on it: pick one page you know is restricted in the Data Center UI and confirm it appears in the result. My reading of that SQL: it inner-joins user_mapping, so restrictions that name only a group drop out of the result. The same article has a by-group variant (WHERE cp.GROUPNAME = '<group_name>'). Run that too.
1SELECT SPACENAME FROM SPACES WHERE SPACEID IN(SELECT SPACEID FROM SPACEPERMISSIONS WHERE PERMTYPE ='VIEWSPACE'AND PERMGROUPNAME ISNULLAND PERMUSERNAME ISNULLAND PERMALLUSERSSUBJECT ISNULL);
The pre-migration checklist says to act on that list: "Before migrating, check for any spaces in Server that have been made publicly available and remove their anonymous access setup, unless content is meant to be public." The same applies in a Cloud-to-Cloud transfer, where "We copy space permissions as they're configured on your source."
Export the Data Center audit log too. The CCMA page is blunt: "The audit logs on server instance are not migrated to the cloud." Once the server is decommissioned, that history is gone.
Recover space permissions for spaces admins cannot see
If a space exists in Cloud but even site admins cannot open it, Atlassian's fix is Recover Permissions. From the knowledge base article: go to the gear icon, then Space Permissions, find the space under Individual Spaces, and choose Recover Permissions. If you are already an admin of the space you will see Manage Permissions instead. The article notes that "Selecting Recover Permissions will be recorded in the Confluence audit log", which matters if you have to account for it later.
It only works on a space that actually migrated: "If the space does not appear in your CCMA migration report or your XML import did not complete successfully, the space may not have migrated — Recover Permissions will not help in that case." I found no REST endpoint for it, and I did not press the button on our test site.
What this does not cover
Here is what this procedure does not cover:
A real migration. The migration effects in the demo were reproduced by hand on one site, not produced by CCMA or a transfer.
Pages the token user cannot see. The script lists pages as the account whose token it uses. Pages whose restrictions exclude that account are probably invisible to it, along with their restrictions. Run it as an account included on restricted pages, and pair it with the Data Center SQL.
Blog posts, attachments and inherited restrictions. It scans pages only and does not expand restrictions inherited from parent pages.
Anonymous access on Cloud. Our site has no anonymous grants, so that path in the script is untested.
Key takeaways
You now have a snapshot of every space permission, role, page restriction and group member on the destination, before and after, as JSON and CSV.
The diff gives you two numbers, missing and new, and exits non-zero when either is not zero.
On Data Center, export grants, restrictions (by user and by group), anonymous spaces and the audit log before you migrate.
Check group names for collisions first, record the roles mode before and after, and use Recover Permissions only for spaces that actually migrated.
"Uncaught (in promise) Error: Unable to emit ready event." in a Forge Confluence macro