$ cd ../writing

githubterraformidentity· 2025-08-19· 6 min

Automating GitHub team management with Terraform and Azure AD

GitHub teams that mirror Azure AD groups, synced on a schedule by Terraform. Group discovery by prefix, nested-group filtering, and the email-to-username lookup problem nobody warns you about.

Somebody joins the company. Somebody changes teams. Somebody leaves.

In most organizations that means a ticket, and the ticket says “please add this person to these GitHub teams”. Then somebody with org admin rights clicks through a web UI, and the org drifts a little further from whatever the org chart says.

We stopped doing that. GitHub teams are now a projection of Azure AD groups, and Terraform reconciles them on a schedule. Nobody clicks anything.

This is the design narration: what the mechanism is, which parts of it are interesting, and the one problem that took the most work to solve.

The source of truth already exists

Identity is not a GitHub problem. Joiners, movers, and leavers are already handled in Azure AD, because that is where accounts get created and disabled, fed by processes that start in HR.

So GitHub should not have an opinion about who exists. It should follow.

That reframe is the whole design. Everything else is plumbing.

Discovery by convention, not configuration

The first thing the configuration does not contain is a list of teams.

data "azuread_groups" "github_groups" {
  display_name_prefix = var.azure_ad_group_prefix
}

Any Azure AD group whose display name starts with the configured prefix (github- by default) is in scope. Create a group with that prefix, and a GitHub team appears on the next run. Nobody edits Terraform to onboard a team.

This is the same convention-over-configuration bet we make everywhere: the naming pattern is the registration. The alternative, a hand-maintained list of group object IDs, is a second source of truth that goes stale the first week nobody is watching.

Group details are then fetched with transitive membership resolved:

data "azuread_group" "group_details" {
  for_each                   = toset(data.azuread_groups.github_groups.object_ids)
  object_id                  = each.value
  include_transitive_members = true
}

The team resource itself is nearly boring, which is the goal:

resource "github_team" "teams" {
  for_each = data.azuread_group.group_details

  name        = each.value.display_name
  description = try(each.value.description, "Synced from Azure AD")
  privacy     = "closed"
}

Nested groups are members too, and that breaks things

Azure AD groups can contain other groups. include_transitive_members flattens the tree for you, which sounds helpful until you realize the member list now contains a mix of user object IDs and group object IDs.

Feed a group object ID into a GitHub user lookup and you get nothing useful. So the group IDs get filtered back out:

locals {
  group_ids = [
    for group in data.azuread_group.group_details : group.object_id
  ]

  # Filter out nested groups and keep only specific members
  filtered_members = {
    for group_id, group in data.azuread_group.group_details : group_id => [
      for member_id in group.members : member_id if !contains(local.group_ids, member_id)
    ]
  }
}

Small block, easy to miss, and the reason the first version of this produced a pile of confusing null lookups. If you build this yourself, this is the bug you will hit.

The actual hard part: Azure AD does not know GitHub usernames

Here is the gap that makes this problem interesting rather than mechanical.

Azure AD knows a person’s work email. GitHub teams need a GitHub username. There is no attribute anywhere that maps one to the other, because a GitHub account is a personal account that predates employment and outlives it.

The bridge is the GitHub user search API, queried by email:

data "http" "github_user_lookup" {
  for_each = local.users_to_lookup

  url = "${var.github_api_url}?q=${each.value.email}"
  request_headers = {
    Accept        = "application/json"
    Authorization = "Bearer ${var.github_token}"
  }
}

This works, with two caveats worth stating plainly.

It only finds people whose work email is on their GitHub account. If someone never added it, or keeps it private, they are invisible to this lookup. That is a real coverage gap, and the honest answer is that it degrades gracefully rather than solving it. Memberships are only created for users who resolved:

resource "github_team_membership" "team_membership" {
  for_each = {
    for mapping in local.team_user_mappings :
    "${mapping.group_id}-${mapping.user_id}" => {
      team_id  = github_team.teams[mapping.group_id].id
      username = local.github_users[mapping.user_id]
    } if local.github_users[mapping.user_id] != null
  }

  team_id  = each.value.team_id
  username = each.value.username
  role     = "member"
}

An unresolvable user is skipped, not a failed apply. One person with a private email should not block the whole org sync.

Search is the most rate-limited part of the GitHub API. One query per user per run, on a schedule, across a whole org, is a lot of search quota spent rediscovering facts that have not changed. Nobody’s GitHub username changes overnight.

Caching in state, because state is a database

The fix is to remember. The resolved mappings are published as an output:

output "github_users" {
  description = "Cached GitHub username mappings for state-based caching"
  value       = local.github_users
}

and fed back in on the next run as previous_github_users. The lookup set then only contains users we have never resolved:

locals {
  previous_successful_lookups = try({
    for user_id, username in var.previous_github_users :
    user_id => username if username != null && username != ""
  }, {})

  users_to_lookup = {
    for user_id in local.member_ids :
    user_id => { email = data.azuread_user.user_details[user_id].mail }
    if !contains(keys(local.previous_successful_lookups), user_id)
  }

  github_users = merge(local.previous_successful_lookups, { /* fresh lookups */ })
}

Steady state: zero search calls. A new joiner: one call. That turns a per-run cost that scales with org size into one that scales with the number of new people, which is the difference between a sync that keeps working and one that starts failing quietly as the org grows.

Note the deliberate asymmetry: only successful lookups are cached. A user who did not resolve gets retried every run, because the fix on their side (adding their work email to GitHub) should take effect without anyone touching Terraform.

The tradeoff is honest. This is a cache in Terraform state, so it can go stale in one specific way: if somebody changes their GitHub username, the cached mapping points at the old one. Rare enough that retry-on-failure plus a state refresh beats paying full lookup cost on every run forever.

What this bought us

  • Team membership follows the org chart with no ticket, no clicking, and no drift between the two.
  • Offboarding stops being a manual checklist item. Removal from the Azure AD group removes the GitHub team membership on the next run.
  • Onboarding a new team is creating a correctly-named group.
  • The whole thing is reviewable. Membership changes show up as a Terraform plan, in a pull request, with a diff.

That last point is the one I would keep if I could only keep one. Access changes becoming a reviewable diff rather than an invisible UI action is worth more than the time saved.

Where it does not reach

Two limits worth naming, since the value of a mechanism is mostly in knowing what it does not do.

The email-to-username lookup is best-effort, as above. Coverage is good, not total.

And this handles membership, not permissions. Which team can do what to which repository is a separate concern, deliberately, and it is the subject of the companion piece on repository management.

Identity flows down from Azure AD. Permissions flow out from the repository configuration. Keeping those two directions separate is what keeps either one comprehensible.


Originally published on LinkedIn, August 2025. This blog is now the canonical version.