Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Team Sync Group Mapping Terraform Module

Drives a GitHub team's membership from your Identity Provider — connect a team to one or more IdP groups (Entra ID, Okta, …) so people are added and removed by directory group, not by hand. Single-resource module over github_team_sync_group_mapping. Built for integrations/github v6.x.

Terraform GitHub provider module type resources


🧩 Overview

  • 🔗 Maps a team to IdP groups. Wraps a single github_team_sync_group_mapping named this, rendering one group block per connection.
  • 🧭 Team is owned elsewhere. Consumes the team's slug by reference (from terraform-github-team) — it never creates the team.
  • 🗂️ Groups are owned by the directory. Each connection carries the group_id, group_name, and group_description discovered from the github_organization_team_sync_groups data source.
  • 🔑 Stable, handle-keyed input. groups is a map(object(...)) keyed on a caller-chosen handle, so reordering or adding connections never churns unrelated group blocks.
  • 🧹 Explicit clearing semantics. An empty groups = {} deassociates every IdP group from the team — an intentional, reviewable operation, not a no-op.
  • 🚫 No tags / timeouts / auth variables. GitHub has no tagging concept; auth and the target org are provider concerns.

💡 Why it matters: Identity-driven team membership means access follows the directory of record. When someone leaves a group in Entra ID/Okta, they leave the GitHub team automatically — closing a common access-review gap for a regulated source supply chain.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

terraform-github-team-sync-group-mapping is an identity-sync module — it consumes a team slug from the module that owns the team, and IdP group identity discovered outside this catalog by the caller; no cataloged module consumes its outputs yet.

flowchart LR
 team["terraform-github-team<br/>(slug output)"]
 idp["github_organization_team_sync_groups<br/>data source (IdP groups, out of suite)"]
 sync["terraform-github-team-sync-group-mapping<br/>(THIS MODULE)"]
 audit["Access-review / IdP audit tooling<br/>(no cataloged module consumer yet)"]

 team -->|"slug"| sync
 idp -->|"group_id / group_name / group_description"| sync
 sync -->|"group_ids / group_names (audit)"| audit

 style sync fill:#8957E5,color:#fff
 style team fill:#24292F,color:#fff
Loading

This module consumes team_slug (from terraform-github-team's slug output) and IdP group identity (from the github_organization_team_sync_groups data source, read by the caller); it emits id/group_ids/group_names for audit and access-review tooling — no module in this catalog consumes them yet, see the Typical wiring section.


🧬 What this module builds

A single authoritative resource with one repeating nested block — no separate child resources.

flowchart TD
 subgraph mod["terraform-github-team-sync-group-mapping"]
 THIS["github_team_sync_group_mapping.this<br/>(keystone)<br/>team_slug + authoritative IdP group set"]
 GROUP["dynamic group block<br/>(for_each var.groups)"]
 end

 THIS --> GROUP

 style THIS fill:#8957E5,color:#fff
Loading

📁 Module Structure

terraform-github-team-sync-group-mapping/
├── providers.tf # terraform{} + required_providers (integrations/github ~> 6.0); NO provider block
├── variables.tf # team_slug (FORCENEW), groups (map(object))
├── main.tf # github_team_sync_group_mapping.this + dynamic "group"
├── outputs.tf # id, team_slug, etag, group_ids, group_names, groups
├── SCOPE.md # resource managed, token scopes, prerequisites, emits, gotchas
└── README.md # this file

⚙️ Quick Start

# Discover the org's available IdP groups (read by the caller, not this module).
data "github_organization_team_sync_groups" "all" {}

module "platform_team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = module.platform_team.slug # from terraform-github-team

  groups = {
    platform_admins = {
      group_id          = "123456"
      group_name        = "platform-admins"
      group_description = "Platform engineering administrators (Entra ID)"
    }
  }
}

🔌 Typical wiring

Derived from the SCOPE.md Emits table — one row per output in outputs.tf.

Output Type Typically consumed by
id string (= team slug) Audit / state references; terraform import target
team_slug string Passthrough for modules keying on the mapped team
etag string Drift/state diagnostics
group_ids map(string) Access-review tooling, downstream IdP audit
group_names map(string) Access-review tooling, human-readable audit
groups set(object) Fidelity readout of the applied group blocks

ℹ️ This module consumes a team slug — wire team_slug = module.<team>.slug from terraform-github-team. It does not feed the keystone terraform-github-repository.


🧠 Architecture Notes

  • id format. id equals the team slug — the same value used for terraform import. This resource exposes no node_id, repo_id, full_name, slug, or html_url, so none are emitted (there are no ARNs on GitHub). The only extra provider attribute is etag.
  • team_slug is ForceNew (resource identity). The team slug is the import id. Changing team_slug is not an in-place rename — Terraform destroys the mapping for the old team and creates a new one for the new team. Treat it as immutable; rename the team upstream and re-import if needed.
  • group_id is a string, not a number. Although IdP group IDs look numeric, the provider models group_id as a string. Pass the value exactly as returned by github_organization_team_sync_groups — do not coerce to a number (tonumber), or you risk perpetual diffs.
  • All three group fields are required. The provider requires group_id, group_name, and group_description on every block. The module enforces this in the type and with validation {} blocks — there are no optional nested fields to guard with try(x, null).
  • Authoritative, not additive. github_team_sync_group_mapping is authoritative for the team: the applied group set is the complete set of IdP connections. There is no per-group additive resource — anything not present in groups is removed. An empty map clears all connections. This is why the module renders one total dynamic "group" block rather than separate per-group resources.
  • This is not github_emu_group_mapping. Team sync (this resource) is for orgs using SAML SSO + team synchronization with a supported IdP. Enterprise Managed Users (EMU) orgs use the separate github_emu_group_mapping resource (which links by integer group_id only). Pick the one that matches your org's identity model — they are not interchangeable.
  • No secrets here. IdP group ids/names/descriptions are directory metadata, not credentials — no output is sensitive. (plaintext_value vs encrypted_value handling does not apply to this resource; it manages no secret values.)
  • Rulesets vs branch protection — N/A. This module governs team membership, not branch policy, so the "prefer rulesets" rule does not apply here.
  • Secondary rate limits on bulk for_each. Mapping many teams at once (a for_each of module calls) issues many org/team writes. GitHub enforces secondary rate limits on bursts of mutating calls; large rollouts may need -parallelism=<n> lowered, or batching, to avoid 403 You have exceeded a secondary rate limit.

🔑 Required token scopes / GitHub App permissions

The provider identity (PAT / GitHub App) must be able to read and write org team membership and read team-sync groups.

Scope / Permission Required for Notes
Classic PAT — admin:org Creating/updating/deleting the team-sync mapping and reading github_organization_team_sync_groups Org-admin-level scope; managing team↔IdP connections is an org administration operation
Classic PAT — read:org Read-only plan / data-source discovery only Insufficient for apply — writes need admin:org
Fine-grained / GitHub App — Organization → Members: Read & write Managing team membership synchronization The fine-grained equivalent of admin:org for teams/members
Fine-grained / GitHub App — Organization → Administration: Read Reading org team-sync group settings Needed for the team-sync groups data source

⚠️ admin:org is a high-privilege, org-wide scope — it can manage every team, member, and org setting. Issue it to a dedicated automation identity, scope it to the single owning org, and store it as a secret (never in code or logs). Team synchronization itself is Enterprise-gated (see Prerequisites) — without it, apply fails even with the correct scope.


🧰 GitHub Prerequisites

  • Edition. Team synchronization is available on GitHub Enterprise Cloud organizations that use SAML SSO (or via an enterprise account). It is not available on Free/Team orgs without SAML SSO. EMU orgs use github_emu_group_mapping instead.
  • Team synchronization must be enabled for the organization, with a supported IdP connection configured: Microsoft Entra ID (Azure AD) or Okta. Team sync must be turned on in Org Settings → Authentication security before any mapping will apply.
  • Groups must already exist in the IdP and be visible to GitHub. Discover the valid group_id / group_name / group_description values with the github_organization_team_sync_groups data source — do not hand-author IDs.
  • The team must already exist (owned by terraform-github-team); this module only maps it.
  • API rate limits. Bulk for_each mappings issue many org writes — watch for secondary rate limits on large rollouts (see Architecture Notes).

⚠️ Enterprise-only flag: team synchronization requires Enterprise Cloud + SAML SSO. Flag this in change tickets; an org on Free/Team will fail apply regardless of token scope.


📚 Example Library

1 · Minimal — map a team to no groups (placeholder / explicit clear)
module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "platform-engineers"
  # groups defaults to {} — the team is mapped to NO IdP groups.
}

⚠️ Applying {} against a team that previously had connections deassociates every group. Review the plan.

2 · Single IdP group (most common)
module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "platform-engineers"

  groups = {
    platform_admins = {
      group_id          = "123456"
      group_name        = "platform-admins"
      group_description = "Platform engineering administrators (Entra ID)"
    }
  }
}
3 · Multiple IdP groups on one team
module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "security-reviewers"

  groups = {
    appsec_team = {
      group_id          = "200001"
      group_name        = "appsec-team"
      group_description = "Application security engineers"
    }
    blue_team = {
      group_id          = "200002"
      group_name        = "blue-team"
      group_description = "Defensive security / detection engineering"
    }
  }
}
4 · Discover groups from the data source, then wire them in
data "github_organization_team_sync_groups" "all" {}

locals {
  appsec = one([
    for g in data.github_organization_team_sync_groups.all.groups :
    g if g.group_name == "appsec-team"
  ])
}

module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "security-reviewers"

  groups = {
    appsec = {
      group_id          = local.appsec.group_id
      group_name        = local.appsec.group_name
      group_description = local.appsec.group_description
    }
  }
}

💡 Sourcing values from the data source guarantees the group_id matches GitHub exactly — the surest way to avoid perpetual diffs.

5 · Cross-module wiring — team_slug from terraform-github-team
module "platform_team" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"
  name   = "platform-engineers"
}

module "platform_team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = module.platform_team.slug

  groups = {
    platform_admins = {
      group_id          = "123456"
      group_name        = "platform-admins"
      group_description = "Platform engineering administrators (Entra ID)"
    }
  }
}
6 · Okta-sourced group
module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "data-platform"

  groups = {
    data_eng = {
      group_id          = "00g1example4Okta7"
      group_name        = "data-engineering"
      group_description = "Data platform engineers (Okta)"
    }
  }
}

ℹ️ Okta group_id values are alphanumeric — another reason group_id is modeled as a string. Pass it verbatim.

7 · for_each at scale — one mapping per team from a map(object)
locals {
  team_group_map = {
    "platform-engineers" = {
      platform_admins = { group_id = "123456", group_name = "platform-admins", group_description = "Platform admins (Entra ID)" }
    }
    "security-reviewers" = {
      appsec = { group_id = "200001", group_name = "appsec-team", group_description = "AppSec engineers" }
    }
    "data-platform" = {
      data_eng = { group_id = "300001", group_name = "data-engineering", group_description = "Data engineers" }
    }
  }
}

module "team_sync" {
  source   = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"
  for_each = local.team_group_map

  team_slug = each.key
  groups    = each.value
}

⚠️ Large for_each rollouts can hit secondary rate limits. Lower -parallelism or batch the apply if you see 403 secondary rate limit.

8 · Secure / hardened variant — directory-sourced, audited
data "github_organization_team_sync_groups" "all" {}

locals {
  # Only allow-listed groups may map; fail the plan if any is missing.
  allowed = ["platform-admins", "platform-oncall"]
  matched = {
    for g in data.github_organization_team_sync_groups.all.groups :
    g.group_name => g if contains(local.allowed, g.group_name)
  }
}

resource "terraform_data" "guard" {
  lifecycle {
    precondition {
      condition     = length(local.matched) == length(local.allowed)
      error_message = "One or more allow-listed IdP groups were not found in the org's team-sync groups."
    }
  }
}

module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "platform-engineers"

  groups = {
    for name, g in local.matched : replace(name, "-", "_") => {
      group_id          = g.group_id
      group_name        = g.group_name
      group_description = g.group_description
    }
  }
}
9 · Re-keying — stable handles independent of group_name
module "team_sync" {
  source = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"

  team_slug = "release-managers"

  groups = {
    # Handle 'primary' stays stable even if the IdP group is renamed.
    primary = {
      group_id          = "410001"
      group_name        = "release-managers-2026"
      group_description = "Release management approvers"
    }
  }
}

💡 Key on a role (primary), not the volatile group_name, so an IdP rename doesn't churn the block.

10 · Reading back what was applied
output "platform_mapped_ids" {
  value = module.team_sync.group_ids # { platform_admins = "123456" }
}

output "platform_applied_groups" {
  value = module.team_sync.groups # full set with descriptions, off the resource
}
11 · Importing an existing mapping
import {
  to = module.team_sync.github_team_sync_group_mapping.this
  id = "platform-engineers" # the team slug IS the import id
}

module "team_sync" {
  source    = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"
  team_slug = "platform-engineers"
  groups    = { /* … match current state … */ }
}
12 · End-to-end — team + membership + IdP mapping
module "platform_team" {
  source  = "git::https://github.com/microsoftexpert/terraform-github-team?ref=v1.0.0"
  name    = "platform-engineers"
  privacy = "closed"
}

data "github_organization_team_sync_groups" "all" {}

module "platform_team_sync" {
  source    = "git::https://github.com/microsoftexpert/terraform-github-team-sync-group-mapping?ref=v1.0.0"
  team_slug = module.platform_team.slug

  groups = {
    for g in data.github_organization_team_sync_groups.all.groups :
    g.group_name => {
      group_id          = g.group_id
      group_name        = g.group_name
      group_description = g.group_description
    } if g.group_name == "platform-admins"
  }
}

🔒 With team sync enabled, do not also manage github_team_membership for the same team — the IdP becomes the source of truth and manual memberships will fight the sync.


📦 Inputs (high-level)

Identity / parent reference

  • team_slug (string, required) — slug of the team whose membership is IdP-driven. ForceNew (the resource identity / import id). Wire from terraform-github-team's slug. Validated non-empty and slug-charset.

IdP connections

  • groups (map(object), default {}) — IdP groups to connect, keyed by a stable caller handle. Each value requires all three fields:
  • group_id (string) — IdP group ID from github_organization_team_sync_groups (string, even if numeric).
  • group_name (string) — IdP group name.
  • group_description (string) — IdP group description.
  • Empty {} maps the team to no groups (clears all connections). All three fields are validated non-empty.

🧾 Outputs

Output Description Notes
id Resource id of the mapping — equals the team slug (the terraform import id) Primary cross-module reference
team_slug Slug of the mapped team (passthrough)
etag Entity tag for the current mapping state, from the GitHub API Drift diagnostics
group_ids Map of caller handle ⇒ group_id for every mapped connection {} when no groups mapped
group_names Map of caller handle ⇒ group_name for every mapped connection {} when no groups mapped
groups The applied set of group blocks (group_id, group_name, group_description) straight off the resource []/empty when none mapped

ℹ️ No output is sensitive — IdP group metadata is directory data, not secrets.


🧱 Design Principles

  • Identity as source of truth. Team membership follows the directory group — access additions and revocations flow from the IdP, supporting clean access reviews.
  • Authoritative by design. The applied group set is complete and total; nothing not listed survives. Clearing is explicit and reviewable.
  • Least privilege, dedicated identity. admin:org is high-privilege — issue it to a scoped automation identity per owning org.
  • No secrets, no tags, no auth in the module. Auth and org are provider concerns; group metadata is non-sensitive directory data.
  • Stable keys, no index churn. for_each over caller handles keeps plans clean as connections change.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output

⚠️ Pin the version. Always source at a tag — ?ref=v1.0.0 — never a branch. Branch sources drift silently between applies.


🔍 Troubleshooting

  • 403 Resource not accessible by integration / scope errors on apply. The token lacks admin:org (classic) or Members: Read & write (fine-grained). read:org is read-only and insufficient for apply.
  • Team synchronization is not enabled (or apply succeeds but membership never changes). The org has not enabled team synchronization with a supported IdP, or it isn't Enterprise Cloud + SAML SSO. Enable team sync in Org Settings → Authentication security first.
  • Perpetual diff on group_id. You coerced the ID to a number, or the value doesn't match the IdP exactly. group_id is a string — pass the data-source value verbatim.
  • group_description required error. All three group fields are mandatory; populate group_description (sourcing from the data source is easiest).
  • Membership unexpectedly emptied. groups = {} (or removing all entries) deassociates every group — this is authoritative behavior, not a bug. Review plans before apply.
  • 403 You have exceeded a secondary rate limit on bulk for_each. Lower -parallelism=<n> or batch the rollout.
  • Members keep flapping in/out of the team. Something else (e.g. github_team_membership) is managing the same team's members while sync is on. Pick one source of truth — let the IdP win.
  • Wrong resource for EMU orgs. On an Enterprise Managed Users org, use github_emu_group_mapping, not this module.

🔗 Related Docs

  • integrations/github provider — github_team_sync_group_mapping resource reference
  • integrations/github provider — github_organization_team_sync_groups data source reference
  • terraform-github-team module — owns the team and emits its slug
  • GitHub Enterprise Cloud — Managing team synchronization for your organization
  • GitHub Enterprise Cloud — Connecting your identity provider (Entra ID / Okta) to your organization

Releases

Packages

Contributors

Languages