Cleaning up unaffiliated users in GitHub Enterprise Cloud with the GraphQL API
GitHub added the concept of unaffiliated users for GHEC accounts a while ago. Enterprise members who don't belong to any organization now show up as _unaffiliated_ , and the changelog suggests that you can "create automation using our remove enterprise member GraphQL API". This is required even if you have SCIM configured at the organization level, because SCIM never touches enterprise membership itself. But what the changelog, however, doesn't tell you _how_ to remove these users using the GraphQL API.
This post shows how to do exactly that with the GitHub CLI.
> Note: On Enterprise Managed Users (EMU) and ghe.com enterprises the mutation is rejected with `FORBIDDEN`, so manage those users through your identity provider
## Prerequisites
* GitHub CLI (`gh`) authenticated as an enterprise owner
* A regular GitHub Enterprise Cloud enterprise.
* The `admin:enterprise` and `read:user` token scopes:
gh auth refresh -h github.com -s admin:enterprise -s read:user
## Step 1: List the unaffiliated users
Unaffiliated users don't consume a license, so they don't appear in the `consumed-licenses` REST endpoint. Query them through GraphQL instead, using the `UNAFFILIATED` membership role:
gh api graphql --paginate -f e=my-enterprise -f query='
query($e: String!, $endCursor: String) {
enterprise(slug: $e) {
id
members(first: 100, after: $endCursor, role: UNAFFILIATED) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
... on User { id login }
... on EnterpriseUserAccount { id login user { id } }
}
}
}
}'
Note the `user { id }` on `EnterpriseUserAccount`: the mutation below needs the ID of the underlying `User`, not the enterprise account.
## Step 2: Decide who to keep
Removing everybody is rarely what you want. Before removing someone, consider excluding:
* members of an enterprise team (`GET /enterprises/{enterprise}/teams` and `/teams/{slug}/memberships`)
* users with a Copilot seat (`GET /enterprises/{enterprise}/copilot/billing/seats`)
* a hand-picked list of protected accounts
## Step 3: Remove the user
Use the `removeEnterpriseMember` mutation with the enterprise ID and the user ID:
gh api graphql `
-f query='mutation($e: ID!, $u: ID!) {
removeEnterpriseMember(input: { enterpriseId: $e, userId: $u }) { clientMutationId }
}' `
-f e=<enterprise-id> -f u=<user-id>
## Step 4: Always dry-run first
Putting steps 1–3 together, save the following as `remove-unaffiliated-users.ps1`. It needs PowerShell 7 and `gh`, and it only prints what it would do when you pass `-DryRun`:
param(
[Parameter(Mandatory)][string]$Enterprise,
[string[]]$ProtectedUsers = @(),
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
# Step 1: all unaffiliated members (gh follows the cursor with --paginate)
$query = @'
query($e: String!, $endCursor: String) {
enterprise(slug: $e) {
id
members(first: 100, after: $endCursor, role: UNAFFILIATED) {
pageInfo { hasNextPage endCursor }
nodes {
... on User { id login }
... on EnterpriseUserAccount { id login user { id } }
}
}
}
}
'@
$pages = gh api graphql --paginate --slurp -f e=$Enterprise -f query=$query | ConvertFrom-Json
$enterpriseId = $pages[0].data.enterprise.id
$members = $pages | ForEach-Object { $_.data.enterprise.members.nodes }
# Step 2: logins to keep
$keep = [System.Collections.Generic.HashSet[string]]::new([string[]]$ProtectedUsers, [StringComparer]::OrdinalIgnoreCase)
$teams = gh api "/enterprises/$Enterprise/teams" --paginate | ConvertFrom-Json
foreach ($team in $teams) {
gh api "/enterprises/$Enterprise/teams/$($team.slug)/memberships" --paginate |
ConvertFrom-Json | ForEach-Object { [void]$keep.Add($_.login) }
}
gh api "/enterprises/$Enterprise/copilot/billing/seats" --paginate --jq '.seats[].assignee.login' |
ForEach-Object { [void]$keep.Add($_) }
# Step 3: remove everybody else
$mutation = @'
mutation($e: ID!, $u: ID!) {
removeEnterpriseMember(input: { enterpriseId: $e, userId: $u }) {
clientMutationId
}
}
'@
foreach ($member in $members) {
if ($keep.Contains($member.login)) { Write-Host "Keeping $($member.login)"; continue }
if ($DryRun) { Write-Host "[DRY RUN] Would remove $($member.login)"; continue }
$userId = $member.user.id ?? $member.id # EnterpriseUserAccount wraps a User
gh api graphql -f query=$mutation -f e=$enterpriseId -f u=$userId | Out-Null
Write-Host "Removed $($member.login)"
}
Run it twice: preview first, then for real.
./remove-unaffiliated-users.ps1 -Enterprise my-enterprise -DryRun
./remove-unaffiliated-users.ps1 -Enterprise my-enterprise -ProtectedUsers admin-bot
Afterwards, re-run the step 1 query: `totalCount` should be `0`. In our testing the count still showed the removed users right after the call and dropped to `0` about 20 seconds later, so wait a moment before concluding that a removal failed.
To keep the list at zero, run the script on a schedule from a GitHub Actions workflow, using a token with the `admin:enterprise` scope stored as a secret.
## Optional: remove users in batches
`removeEnterpriseMember` accepts a single user, but GraphQL lets you send several aliased mutations in one request. Replace the removal loop in the script with this to remove users 20 at a time:
$toRemove = @($members | Where-Object { -not $keep.Contains($_.login) })
$retry = @()
for ($start = 0; $start -lt $toRemove.Count; $start += 20) {
$batchMembers = @($toRemove[$start..([math]::Min($start + 19, $toRemove.Count - 1))])
# One aliased mutation per user, each with its own variable ($u0, $u1, ...)
$variables = for ($i = 0; $i -lt $batchMembers.Count; $i++) { "`$u${i}: ID!" }
$fields = for ($i = 0; $i -lt $batchMembers.Count; $i++) {
@"
m${i}: removeEnterpriseMember(input: { enterpriseId: `$e, userId: `$u${i} }) {
clientMutationId
}
"@
}
$query = "mutation(`$e: ID!, $($variables -join ', ')) {`n$($fields -join "`n")`n}"
$ghArgs = @("api", "graphql", "-f", "query=$query", "-f", "e=$enterpriseId")
for ($i = 0; $i -lt $batchMembers.Count; $i++) {
$ghArgs += @("-f", "u$i=$($batchMembers[$i].user.id ?? $batchMembers[$i].id)")
}
# gh exits non-zero when any alias fails, but still prints the response, so capture it either way
$response = gh @ghArgs 2>$null | ConvertFrom-Json
for ($i = 0; $i -lt $batchMembers.Count; $i++) {
$failure = $response.errors | Where-Object { $_.path[0] -eq "m$i" }
if ($failure) {
Write-Host "Failed $($batchMembers[$i].login): $($failure.message)"
$retry += $batchMembers[$i]
} else {
Write-Host "Removed $($batchMembers[$i].login)"
}
}
}
Keep batches small (10–25). The mutations run one after another on the server, so batching saves round trips rather than time, and very large batches can trigger GitHub's secondary rate limits.
### Reading the errors
A batch can partly succeed. The response then contains both a `data` object and an `errors` array:
{
"data": {
"m0": { "clientMutationId": null }, // succeeded
"m1": null, // failed
"m2": { "clientMutationId": null } // succeeded
},
"errors": [
{
"type": "NOT_FOUND",
"path": ["m1"],
"message": "Could not resolve to a node with the global id of 'U_kgDOAAAAAA'."
}
]
}
This is a real response from a batch of three: two real unaffiliated users (`m0`, `m2`) and one made-up ID (`m1`). The two real users were removed, the made-up ID failed, and `gh` exited with code 1 because `errors` was present. The full response is still printed, so capture it either way. The response may also contain an `extensions.warnings` block with ID deprecation notices; ignore it, or switch to the `next_global_id` it mentions.
* Every alias (`m0`, `m1`, …) maps to the user at the same position in the batch.
* An alias fails when `errors` contains an entry whose `path[0]` is that alias. Every other alias was removed.
* If the response has `errors` but no `path` (for example a rate limit or authentication error), the whole request failed and nobody was removed. Retry the entire batch.
### Which users to retry
* `NOT_FOUND` ("Could not resolve to a node"): the user was already removed, deleted their account, or you passed the wrong ID type. Don't retry; re-run the step 1 query to see who is still unaffiliated.
* `FORBIDDEN` with "unavailable for Enterprise Managed User (EMU) enterprises": the mutation isn't supported on EMU and enterprises, so retrying never helps. Manage those users through your identity provider instead.
* Rate limit or timeout (`RATE_LIMITED`, `502`, `abuse`/`secondary rate limit` messages): wait a minute and retry those users, ideally in smaller batches.
* Permission errors (`FORBIDDEN`, `INSUFFICIENT_SCOPES`): retrying won't help. Fix the token scopes first.
Simplest and safest approach: after the loop, don't bother tracking `$retry`; just run the script again. It queries the current unaffiliated list, so it only picks up whoever is still there, and `-DryRun` shows exactly which users remain.
## Learn more
* Changelog: Unaffiliated users for GHEC accounts now generally available
* GraphQL removeEnterpriseMember mutation