Skip to content

microsoftexpert/tf-mod-github-team

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Team Terraform Module

One boundary for a GitHub team and everything that hangs off it — the team itself plus its code-review settings, authoritative membership, and repository grants, all wired through a single team_id. Secure-by-default (closed privacy, least-privilege member / pull roles), deeply-typed object inputs, and for_each collections keyed on stable strings. Built for integrations/github v6.x.

Terraform GitHub provider module type resources


🧩 Overview

This composite module provisions a complete GitHub team as one unit:

  • 👥 The team (github_team.this) — name, description, privacy, notifications, optional nesting under a parent team.
  • ⚙️ Code-review settings (github_team_settings.settings) — optional review-request auto-assignment (round-robin / load-balance) via the v4 GraphQL API.
  • 🔑 Authoritative membership (github_team_membership.members) — a for_each map of username → role; the module owns the full roster.
  • 📦 Repository grants (github_team_repository.repos) — a for_each map of repository → permission, granting the team access to existing repos.

💡 Why it matters: a team's people, its repo access, and its review automation drift apart when managed in three separate places. This module makes the team's roster and grants declarative and authoritative — what's in the map is what exists, nothing more — so access reviews become a diff, not an investigation.


❤️ 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

flowchart LR
 repo["tf-mod-github-repository<br/>(keystone)"]
 team["tf-mod-github-team<br/>(THIS)"]
 sync["tf-mod-github-team-sync-group-mapping"]
 roles["tf-mod-github-organization-roles"]
 collab["tf-mod-github-repository-collaborators"]
 ruleset["tf-mod-github-repository-ruleset"]
 nested["tf-mod-github-team<br/>(nested child)"]

 repo -->|"id (repo name)"| team
 team -->|"slug / id"| sync
 team -->|"id (role-team)"| roles
 team -->|"slug (team block)"| collab
 team -->|"id (bypass actor)"| ruleset
 team -->|"id (parent_team_id)"| nested

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

This module consumes repository names from tf-mod-github-repository (id) for its repositories grants and, optionally, a parent team's id from another tf-mod-github-team. It emits id (parent for nested teams, role-team, ruleset bypass actor) and slug (CODEOWNERS, collaborator team blocks, team-sync mappings). See the Cross-Module Contract.


🧬 What this module builds

flowchart TD
 this["github_team.this<br/>(the team)"]
 settings["github_team_settings.settings<br/>(optional singleton)"]
 members["github_team_membership.members<br/>(for_each: username → role)"]
 repos["github_team_repository.repos<br/>(for_each: repository → permission)"]

 this -->|team_id| settings
 this -->|team_id| members
 this -->|team_id| repos

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

Resource inventory

  • github_team.this — the primary team resource; everything else references its id.
  • github_team_settings.settings — optional singleton (created only when var.settings is set) holding code-review notification and review-request delegation.
  • github_team_membership.members — one resource per username; the module's for_each makes the roster authoritative.
  • github_team_repository.repos — one resource per repository; grants the team a permission level on an already-existing repo.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider integrations/github ~> 6.0 (current 6.12.1)

⚠️ Source is integrations/github, never the deprecated hashicorp/github. Migrate old state with terraform state replace-provider registry.terraform.io/hashicorp/github registry.terraform.io/integrations/github.

ℹ️ Schema notes that bite: github_team_settings relies on the v4 GraphQL API (Stone Crop schema) — the presence of a review_request_delegation block (even empty) enables delegation. ldap_dn is GitHub Enterprise Server only and silently ignored on github.com / Enterprise Cloud.


📁 Module Structure

tf-mod-github-team/
├── providers.tf # terraform{} + required_providers (integrations/github ~> 6.0)
├── variables.tf # name, parent_team_id, privacy, settings, members, repositories …
├── main.tf # github_team.this + settings / members / repos collections
├── outputs.tf # id, node_id, slug, name, *_ids, settings_team_uid
├── SCOPE.md # design contract: in-scope, consumes/emits, token scopes, prereqs
└── README.md # this file

⚙️ Quick Start

module "platform_team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name        = "Platform Engineering"
  description = "Owns the shared platform and infrastructure modules."

  members = {
    "casey-wood" = { role = "maintainer" }
    "octocat"    = {} # defaults to role = "member"
  }

  repositories = {
    (module.platform_api.id) = { permission = "push" } # repo name from tf-mod-github-repository
  }
}

💡 repositories is keyed by repository name, which is exactly the id output of tf-mod-github-repository — wire them directly.


🔌 Cross-Module Contract

Consumes

Input Type Source
repositories[*] (key) string (repo name) tf-mod-github-repository (id)
parent_team_id string (team id or slug) another tf-mod-github-team (id) — nested teams

Emits

Output Description Consumed by
id Numeric team ID tf-mod-github-team (parent_team_id), tf-mod-github-organization-roles (role-team), tf-mod-github-repository-ruleset (bypass actor)
node_id GraphQL global node ID GraphQL automation
slug URL-safe team slug tf-mod-github-repository-collaborators (team blocks), CODEOWNERS, tf-mod-github-team-sync-group-mapping
name The team's full name Reporting / display
members_count Number of members managed Reporting
membership_ids Map: username → github_team_membership id Downstream auditing
repository_ids Map: repo → github_team_repository id Downstream auditing
settings_team_uid Team node ID from settings (null when unmanaged) GraphQL automation

📚 Example Library

1️⃣ Minimal — just the team
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name = "Security Champions"
}

Creates a single closed team with no members, grants, or settings.

2️⃣ Description + notifications off
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name                 = "On-Call Rotation"
  description          = "Pager rotation — high-volume, mentions muted."
  notification_setting = "notifications_disabled"
}
3️⃣ Authoritative membership (member vs maintainer)
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name = "Lending Core"

  members = {
    "casey-wood" = { role = "maintainer" }
    "octocat"    = { role = "member" }
    "hubot"      = {} # role defaults to "member"
  }
}

🔒 The roster is authoritative — a user removed from this map is removed from the team on the next apply. Do not also manage these members elsewhere.

4️⃣ Nested team under a parent
module "parent" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  name   = "Engineering"
}

module "child" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name           = "Engineering — Backend"
  parent_team_id = module.parent.id # numeric id or slug
  privacy        = "closed"         # required for nesting
}

⚠️ A secret team cannot be nested. Keep privacy = "closed" (the default) whenever parent_team_id is set.

5️⃣ Repository grants (least privilege)
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name = "Docs Maintainers"

  repositories = {
    "developer-portal" = { permission = "push" }
    "api-reference"    = {} # permission defaults to "pull" (read-only)
  }
}

Built-in roles: pull, triage, push, maintain, admin. A custom repository role name (Enterprise Cloud) is also accepted.

6️⃣ Code-review auto-assignment (round-robin)
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name = "Reviewers"

  settings = {
    notify = true
    review_request_delegation = {
      algorithm    = "ROUND_ROBIN"
      member_count = 2
    }
  }
}

💡 The mere presence of review_request_delegation enables delegation. Omit the block to keep settings without auto-assignment; set settings = null to manage no settings at all.

7️⃣ Load-balanced review assignment
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name = "Frontend Guild"

  settings = {
    review_request_delegation = {
      algorithm    = "LOAD_BALANCE"
      member_count = 1
    }
  }
}

LOAD_BALANCE spreads requests by each member's outstanding review count rather than strict rotation.

8️⃣ Members + repos + settings combined
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name        = "Payments"
  description = "Owns the payments service and its release tooling."

  members = {
    "casey-wood" = { role = "maintainer" }
    "octocat"    = { role = "member" }
  }

  repositories = {
    "payments-service" = { permission = "maintain" }
    "payments-infra"   = { permission = "push" }
  }

  settings = {
    notify                    = true
    review_request_delegation = { algorithm = "ROUND_ROBIN", member_count = 1 }
  }
}
9️⃣ Repository wired from tf-mod-github-repository
module "platform_api" {
  source     = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  name       = "platform-api"
  visibility = "private"
}

module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name = "Platform"

  repositories = {
    (module.platform_api.id) = { permission = "push" } # id == repo name
  }
}

💡 module.platform_api.id is the repo name — use it directly as the repositories map key.

🔟 Secure / hardened variant
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"

  name        = "Prod Release Approvers"
  description = "Tightly scoped; least privilege everywhere."
  privacy     = "closed" # discoverable, never secret-but-orphaned

  # Authoritative roster — exactly these people, one maintainer.
  members = {
    "casey-wood"  = { role = "maintainer" }
    "release-bot" = { role = "member" }
  }

  # Read-only by default; widen per-repo only where required.
  repositories = {
    "prod-config"   = { permission = "pull" }
    "release-tools" = { permission = "push" }
  }
}

🔒 Defaults already enforce least privilege: privacy = "closed", role = "member", permission = "pull". Every broader grant above is an explicit opt-in.

1️⃣1️⃣ for_each at scale — fleet of teams from a map
locals {
  teams = {
    "data-platform" = {
      description = "Owns the lakehouse and ETL."
      members     = { "casey-wood" = { role = "maintainer" }, "octocat" = {} }
      repos       = { "lakehouse" = { permission = "maintain" } }
    }
    "ml-platform" = {
      description = "Owns model training infra."
      members     = { "hubot" = { role = "maintainer" } }
      repos       = { "ml-infra" = { permission = "push" } }
    }
  }
}

module "team" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  for_each = local.teams

  name         = each.key
  description  = each.value.description
  members      = each.value.members
  repositories = each.value.repos
}

⚠️ Each team, member, and repo grant is one API call. A large for_each can trip secondary rate limits — see Troubleshooting.

1️⃣2️⃣ Team as a ruleset bypass actor
module "admins" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  name   = "Repo Admins"
}

# Pass module.admins.id into a ruleset's bypass_actors (actor_type = "Team").
module "ruleset" {
  source         = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  repository     = module.platform_api.id
  bypass_team_id = module.admins.id
  # …ruleset config…
}

💡 Rulesets reference teams by numeric id as a bypass actor; CODEOWNERS and collaborator blocks reference them by slug. The module emits both.

1️⃣3️⃣ Team-sync group mapping (consumes slug)
module "team" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  name   = "SSO-Backed Engineers"
}

module "team_sync" {
  source    = "git::https://github.com/microsoftexpert/tf-mod-github-team-sync-group-mapping?ref=v1.0.0"
  team_slug = module.team.slug # IdP-backed membership
  # …group mappings…
}

⚠️ When team-sync owns membership, do not also populate members here — pick one source of truth to avoid a fight on every apply.

🏗️ 1️⃣4️⃣ End-to-end composition (full suite, outputs → inputs)
# 1) Keystone repository
module "platform_api" {
  source               = "git::https://github.com/microsoftexpert/tf-mod-github-repository?ref=v1.0.0"
  name                 = "platform-api"
  visibility           = "private"
  vulnerability_alerts = true
}

# 2) Parent team
module "engineering" {
  source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  name   = "Engineering"
}

# 3) Nested team that owns the repo, with review automation
module "platform_team" {
  source         = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
  name           = "Platform"
  parent_team_id = module.engineering.id
  privacy        = "closed"

  members = {
    "casey-wood" = { role = "maintainer" }
    "octocat"    = { role = "member" }
  }

  repositories = {
    (module.platform_api.id) = { permission = "maintain" }
  }

  settings = {
    notify                    = true
    review_request_delegation = { algorithm = "ROUND_ROBIN", member_count = 1 }
  }
}

# 4) Protect the default branch; the team bypasses
module "ruleset" {
  source         = "git::https://github.com/microsoftexpert/tf-mod-github-repository-ruleset?ref=v1.0.0"
  repository     = module.platform_api.id
  enforcement    = "active"
  bypass_team_id = module.platform_team.id
}

The repository's id feeds both the team's repositories grant and the ruleset's repository; the team's id feeds the parent relationship and the ruleset's bypass actor. One graph, wired entirely through outputs → inputs.


📥 Inputs

Identity & hierarchy

  • name (required) — the team name; the slug is recomputed from it.
  • parent_team_id — parent team id/slug for nesting (null = root team).

Team configuration

  • description, privacy (closed / secret), notification_setting, ldap_dn (Enterprise Server only).

Collections & settings

  • settings — optional code-review settings object (null disables the resource).
  • membersmap(object) of username → role; authoritative.
  • repositoriesmap(object) of repository → permission.
Full object schemas
settings = object({
  notify = optional(bool, false)
  review_request_delegation = optional(object({
    algorithm    = optional(string, "ROUND_ROBIN") # ROUND_ROBIN | LOAD_BALANCE
    member_count = optional(number, 1)
  }))
}) # default: null

members = map(object({
  role = optional(string, "member") # member | maintainer
}))                                 # default: {}

repositories = map(object({
  permission = optional(string, "pull") # pull | triage | push | maintain | admin | <custom role>
}))                                     # default: {}

🧾 Outputs

Output Description Notes
id Numeric team ID Primary cross-module reference
node_id GraphQL global node ID
slug URL-safe team slug What CODEOWNERS / collaborators expect
name Team's full name
members_count Number of managed members
membership_ids Map: username → membership id Keyed like var.members
repository_ids Map: repo → grant id Keyed like var.repositories
settings_team_uid Team node id from settings null when settings unmanaged

ℹ️ No sensitive outputs — a team exposes no secret material.


🧠 Architecture Notes

  • id vs node_id vs slug. id is the numeric REST identifier (parent teams, role-teams, ruleset bypass actors consume it). node_id is the opaque GraphQL global id. slug is the URL-safe handle — and it is what CODEOWNERS, collaborator team blocks, and team-sync mappings reference, not id. The module emits all three so callers never guess.
  • parent_team_id is in-place, not ForceNew. Re-parenting a team updates it in place — but it changes inherited access, so treat it as a security-relevant change in review.
  • secret privacy excludes nesting. A secret team cannot have a parent. defaults to closed (provider default is secret) precisely so teams stay nestable and discoverable.
  • Authoritative vs additive membership. github_team_membership is additive per user at the provider level, but the module drives it with for_each over the whole members map — so the roster is authoritative: anyone not in the map is removed. Don't mix this with externally-managed membership or team-sync on the same team.
  • Settings ride the v4 GraphQL API. github_team_settings uses the Stone Crop GraphQL schema. The presence of a review_request_delegation block (even empty) enables delegation — there is no separate boolean.
  • Rulesets vs branch protection. This module governs who is on a team and what they can reach, not branch rules. Branch enforcement lives in tf-mod-github-repository-ruleset (preferred) or the legacy github_branch_protection; a team here is typically wired in as a bypass actor by id.
  • Eventual consistency & secondary rate limits. The GitHub REST API is eventually consistent — a freshly created team/member may not be immediately visible, and bulk for_each collections issue one call per entry, which can trip secondary rate limits. See Troubleshooting.
  • No tags, no timeouts, no ARNs. GitHub has none of these; the module exposes id / node_id / slug instead.

🧱 Design Principles

  • 🔒 Least privilege by defaultrole = "member", permission = "pull". Broader access is always an explicit opt-in.
  • 👁️ Discoverable, not secretprivacy = "closed" by default so teams participate in the hierarchy and access reviews.
  • 📜 Authoritative collections — the members and repositories maps are the desired state; drift is removed, not accumulated.
  • 🧱 One owner per relationship — team↔repo grants live here (the team's view); repo-centric grants live in tf-mod-github-repository-collaborators (the repo's view). Pick one per relationship.
  • 🔑 Auth is a provider concern — no owner / token / app_auth variables ever appear in this module.

🚀 Runbook

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

⚠️ Always pin the module source to a tag — ?ref=v1.0.0 — never a branch. Branches move; releases don't.


🧪 Testing

The offline proof gate (no live org required):

terraform fmt -check # zero formatting differences
terraform validate # zero errors
tflint # core rules — no dedicated GitHub ruleset exists

ℹ️ plan / apply require a configured integrations/github provider (PAT / GitHub App) with admin:org; keep them out of the offline gate.


💬 Example Output

Outputs:

id = "7654321"
node_id = "T_kwDOABCD1234"
slug = "platform"
name = "Platform"
members_count = 2
membership_ids = {
 "casey-wood" = "7654321:casey-wood"
 "octocat" = "7654321:octocat"
}
repository_ids = {
 "platform-api" = "7654321:platform-api"
}
settings_team_uid = "T_kwDOABCD1234"

🔍 Troubleshooting

Symptom Cause Resolution
403 / Resource not accessible on team create Identity lacks org-owner / team-maintainer rights Use a PAT with admin:org (or a GitHub App with Members: read/write); the identity must be an org owner.
422 re-parenting fails / secret team rejected for nesting privacy = "secret" on a team with a parent_team_id Set privacy = "closed" (the default) whenever nesting.
Member keeps reappearing after removal Membership also managed by team-sync or another module Choose one owner. If SSO/IdP owns it, leave members = {} and use tf-mod-github-team-sync-group-mapping.
repository... not found on a grant Repo doesn't exist yet (grants don't create repos) Create it via tf-mod-github-repository first and wire its id as the map key.
You have exceeded a secondary rate limit Large for_each issuing many calls in a burst Apply with reduced -parallelism, split into smaller applies, or retry after a backoff.
github_team_settings errors / no effect v4 GraphQL not available, or delegation block omitted Confirm GraphQL access; remember the presence of review_request_delegation is what enables it.
ldap_dn ignored On github.com / Enterprise Cloud ldap_dn is Enterprise Server only; leave it null elsewhere.

🔗 Related Docs

  • tf-mod-github-repository — the keystone that emits repo names consumed here
  • tf-mod-github-repository-ruleset — branch governance; consumes the team id as a bypass actor
  • tf-mod-github-team-sync-group-mapping — IdP-backed team membership; consumes the team slug
  • tf-mod-github-repository-collaborators — the repo-centric counterpart to repositories grants
  • integrations/github provider reference — github_team, github_team_settings, github_team_membership, github_team_repository

💙 "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages