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. The changelog suggests that you can "create automation using our remove enterprise member GraphQL API". It doesn't tell you how to remove these users using said 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:enterpriseandread:usertoken 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}/teamsand/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
errorscontains an entry whosepath[0]is that alias. Every other alias was removed. - If the response has
errorsbut nopath(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.FORBIDDENwith "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 limitmessages): 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.