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.

Cleaning up unaffiliated users in GitHub Enterprise Cloud with the GraphQL API
Photo by Marija Zaric / Unsplash

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