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.
- 🔗 Maps a team to IdP groups. Wraps a single
github_team_sync_group_mappingnamedthis, rendering onegroupblock 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, andgroup_descriptiondiscovered from thegithub_organization_team_sync_groupsdata source. - 🔑 Stable, handle-keyed input.
groupsis amap(object(...))keyed on a caller-chosen handle, so reordering or adding connections never churns unrelatedgroupblocks. - 🧹 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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
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.
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
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
# 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)"
}
}
}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>.slugfromterraform-github-team. It does not feed the keystoneterraform-github-repository.
idformat.idequals the team slug — the same value used forterraform import. This resource exposes nonode_id,repo_id,full_name,slug, orhtml_url, so none are emitted (there are no ARNs on GitHub). The only extra provider attribute isetag.team_slugis ForceNew (resource identity). The team slug is the import id. Changingteam_slugis 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_idis a string, not a number. Although IdP group IDs look numeric, the provider modelsgroup_idas a string. Pass the value exactly as returned bygithub_organization_team_sync_groups— do not coerce to a number (tonumber), or you risk perpetual diffs.- All three
groupfields are required. The provider requiresgroup_id,group_name, andgroup_descriptionon every block. The module enforces this in the type and withvalidation {}blocks — there are no optional nested fields to guard withtry(x, null). - Authoritative, not additive.
github_team_sync_group_mappingis authoritative for the team: the appliedgroupset is the complete set of IdP connections. There is no per-group additive resource — anything not present ingroupsis removed. An empty map clears all connections. This is why the module renders one totaldynamic "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 separategithub_emu_group_mappingresource (which links by integergroup_idonly). 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_valuevsencrypted_valuehandling 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 (afor_eachof 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 avoid403 You have exceeded a secondary rate limit.
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:orgis 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,applyfails even with the correct scope.
- 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_mappinginstead. - 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_descriptionvalues with thegithub_organization_team_sync_groupsdata 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_eachmappings 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 failapplyregardless of token scope.
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_idmatches 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_idvalues are alphanumeric — another reasongroup_idis 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
}
⚠️ Largefor_eachrollouts can hit secondary rate limits. Lower-parallelismor batch the apply if you see403 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 volatilegroup_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_membershipfor the same team — the IdP becomes the source of truth and manual memberships will fight the sync.
Identity / parent reference
team_slug(string, required) — slug of the team whose membership is IdP-driven. ForceNew (the resource identity / import id). Wire fromterraform-github-team'sslug. 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 fromgithub_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.
| 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.
- 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
groupset is complete and total; nothing not listed survives. Clearing is explicit and reviewable. - Least privilege, dedicated identity.
admin:orgis 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_eachover caller handles keeps plans clean as connections change.
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.
403 Resource not accessible by integration/ scope errors on apply. The token lacksadmin:org(classic) or Members: Read & write (fine-grained).read:orgis read-only and insufficient forapply.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_idis a string — pass the data-source value verbatim. group_descriptionrequired error. All threegroupfields are mandatory; populategroup_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 limiton bulkfor_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.
- integrations/github provider —
github_team_sync_group_mappingresource reference - integrations/github provider —
github_organization_team_sync_groupsdata source reference terraform-github-teammodule — owns the team and emits itsslug- GitHub Enterprise Cloud — Managing team synchronization for your organization
- GitHub Enterprise Cloud — Connecting your identity provider (Entra ID / Okta) to your organization