Authoritative, least-privilege collaborator management for a single GitHub repository — declares the complete set of user and team collaborators (plus an
ignore_teamescape hatch for externally-managed teams) behind one clean module boundary. Built for integrations/github v6.x.
This module wraps a single github_repository_collaborators resource (this) and gives you:
- 👥 User collaborators — a
map(object)keyed by GitHub login, each with an optionalpermission. - 🏷️ Team collaborators — a
map(object)keyed by team ID or slug, each with an optionalpermission. - 🛡️ Least-privilege defaults — every collaborator defaults to
pull(the provider's native default ispush), so access is read-only unless you opt up. - 🧹 Authoritative reconciliation — the resource owns the entire collaborator set; anything attached out-of-band is removed on apply, eliminating access drift.
- 🤝
ignore_teamescape hatch — exempt org/enterprise teams attached by SSO group sync or external automation from authoritative removal, so they don't fight Terraform. - 📨 Invitation tracking — the
invitation_idsoutput surfaces pending (unaccepted) user invitations. - ✅ Closed-set validation —
permissionis validated againstpull | triage | push | maintain | admin; keys are validated non-empty.
💡 Why it matters: Collaborator sprawl is how stale access accumulates. Because this resource is authoritative, your Terraform state becomes the single source of truth for who can touch the repo — review the diff, and you've reviewed the access model.
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-repository-collaborators is an access-management module — it consumes a repository name and team identities from the objects that own them, and hands its repository passthrough onward to the sibling modules that enforce or notify on top of that same repo.
flowchart LR
repo["terraform-github-repository<br/>(repository name)"]
team["terraform-github-team<br/>(team slug / id)"]
collab["terraform-github-repository-collaborators<br/>(THIS MODULE)"]
ruleset["terraform-github-repository-ruleset"]
webhook["terraform-github-repository-webhook"]
branchprot["terraform-github-branch-protection"]
repo -->|"name / id"| collab
team -->|"slug / id"| collab
collab -->|"repository"| ruleset
collab -->|"repository"| webhook
collab -->|"repository"| branchprot
style collab fill:#8957E5,color:#fff
style repo fill:#24292F,color:#fff
This module consumes repository (from terraform-github-repository's name/id) and teams keys (from terraform-github-team's slug/id); it emits repository and repository_id for the sibling ruleset/webhook/branch-protection modules to key on — see the Cross-Module Contract.
This module renders a single authoritative resource with three repeating nested blocks, no separate child resources.
flowchart TD
subgraph mod["terraform-github-repository-collaborators"]
THIS["github_repository_collaborators.this<br/>(keystone)<br/>authoritative user/team collaborator set"]
USER["dynamic user block<br/>(for_each var.users)"]
TEAM["dynamic team block<br/>(for_each var.teams)"]
IGNORE["dynamic ignore_team block<br/>(for_each var.ignore_teams)"]
end
THIS --> USER
THIS --> TEAM
THIS --> IGNORE
style THIS fill:#8957E5,color:#fff
terraform-github-repository-collaborators/
├── providers.tf # terraform >= 1.12.0, integrations/github ~> 6.0
├── variables.tf # repository, users, teams, ignore_teams
├── main.tf # github_repository_collaborators.this (+ dynamic user/team/ignore_team)
├── outputs.tf # id, repository, repository_id, invitation_ids
├── SCOPE.md # scope contract, token scopes, prerequisites, gotchas
└── README.md # this file
module "repo_collaborators" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = module.repository.name # wire from terraform-github-repository
users = {
"octocat" = { permission = "push" }
"hubot" = {} # defaults to least-privilege "pull"
}
teams = {
"platform-engineering" = { permission = "maintain" }
}
}
⚠️ Theowner/organization and authentication (PAT / GitHub App) are provider concerns — configure them on the caller'sprovider "github"block, never as module inputs.
Derived from SCOPE.md Emits — one row per output.
| Output | Type | Typically consumed by |
|---|---|---|
id |
string (repo name) |
Downstream modules / depends_on ordering; equals the repository name |
repository |
string |
Passthrough for chaining sibling repo-scoped modules (terraform-github-repository-ruleset, terraform-github-repository-webhook, terraform-github-branch-protection) |
repository_id |
number |
Modules/data sources that key on the numeric repository ID |
invitation_ids |
map(string) |
Automation that resends/expires pending user invitations |
idis the repository name.github_repository_collaboratorsuses the repository name as its resourceid(andoutput.idreturns exactly that). There is no node-id form for this resource — it manages an association set, not a first-class GitHub object.repository_idis the numeric repo ID for callers that need it; there is nonode_id/slug/html_urlhere because the resource has none.repositoryis ForceNew (immutable). The resourceidis the repository name, so changingvar.repositorytargets a different repository — Terraform destroys and recreates the collaborators resource. Treat repo renames as a recreate, not an in-place update.- Authoritative (plural) resource — chosen deliberately.
github_repository_collaborators(plural) manages the complete collaborator set: any user or team attached to the repo but absent fromusers/teams(and not inignore_teams) is removed on apply. This is the opposite of the additive singular resources (github_repository_collaborator/github_team_repository), which only manage their own entry and leave others untouched. prefers the authoritative resource because it makes access declarative and drift-free — the state is the access model. The trade-off: every legitimately-out-of-band team must be enrolled inteamsor exempted viaignore_teams, or it will be churned on every apply. ignore_teamsis the only safe coexistence path. Teams attached by SSO group sync, enterprise automation, or another Terraform state will otherwise be stripped by the authoritative reconcile. List them inignore_teams(renders oneignore_teamblock each) to leave them alone. There is no equivalentignore_user— externally-managed user collaborators must be added tousersor they will be removed.- Permissions & custom roles. Built-in permissions are
pull | triage | push | maintain | admin, validated by the module. Custom repository roles (GitHub Enterprise Cloud) are accepted by the provider as a permission string but will fail this module'svalidation {}— relax it if you depend on them. On a personal (non-org) repository the only valid non-owner permission ispush. - Invitations vs membership. Adding a user sends an invitation; the collaborator isn't active until accepted. Pending invitations appear in
invitation_ids(username ⇒ invitation ID) and clear once accepted. Teams do not use invitations — team access is immediate. - No secrets here. This module handles no secret values, so there is no
plaintext_valuevsencrypted_valueconsideration and nosensitiveinputs/outputs. (Mentioned because the standard asks: not applicable to this resource.) - Branch protection / rulesets are out of scope. This module governs who can access the repo, not what they may push. Pair it with
terraform-github-repository-ruleset(our preferred enforcement path over the legacygithub_branch_protectionv3 API) for branch/push rules. - Secondary rate limits on bulk
for_each. Largeusers/teamsmaps issue many serial collaborator/invitation API calls and can trip GitHub's secondary rate limit (HTTP 403 with aRetry-After). For big sets, apply in batches, setparallelismlow, or expect Terraform's automatic retry/backoff to pace the run.
1 · Minimal — one read-only user
module "collab_minimal" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "platform-api"
users = {
"octocat" = {} # defaults to "pull"
}
}2 · Explicit per-user permissions
module "collab_users" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "platform-api"
users = {
"alice" = { permission = "admin" }
"bob" = { permission = "push" }
"carol" = { permission = "triage" }
"dave" = {} # pull
}
}3 · Team collaborators only
module "collab_teams" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "platform-api"
teams = {
"platform-engineering" = { permission = "maintain" }
"security-reviewers" = { permission = "triage" }
}
}4 · Mixed users and teams
module "collab_mixed" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "platform-api"
users = {
"release-bot" = { permission = "push" }
}
teams = {
"platform-engineering" = { permission = "maintain" }
"qa" = { permission = "pull" }
}
}5 · `ignore_teams` — coexist with SSO group sync
module "collab_ignore" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "platform-api"
teams = {
"platform-engineering" = { permission = "maintain" }
}
# These teams are attached by enterprise SSO group sync — do not churn them.
ignore_teams = ["sso-all-engineers", "compliance-auditors"]
}💡 Without
ignore_teams, the authoritative reconcile would strip the SSO-synced teams on every apply, producing perpetual drift.
6 · Team keyed by numeric ID vs slug
module "collab_team_ids" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "platform-api"
teams = {
"1234567" = { permission = "push" } # numeric team ID
"security-reviewers" = { permission = "triage" } # team slug
}
}7 · Secure / hardened variant — least privilege everywhere
module "collab_hardened" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "regulated-loan-service"
# No standing admins. Humans get triage; a single break-glass team gets maintain.
users = {
"audit-readonly" = { permission = "pull" }
}
teams = {
"platform-engineering" = { permission = "maintain" }
"security-reviewers" = { permission = "triage" }
}
ignore_teams = ["compliance-auditors"] # managed by enterprise automation
}🔒 Authoritative management means anyone added directly on GitHub.com is removed on the next apply — exactly what you want for a regulated repository.
8 · `for_each` at scale from a `map(object)`
locals {
repo_access = {
"platform-api" = {
users = { "release-bot" = { permission = "push" } }
teams = { "platform-engineering" = { permission = "maintain" } }
}
"platform-web" = {
users = {}
teams = { "frontend" = { permission = "push" }, "qa" = { permission = "pull" } }
}
"data-pipeline" = {
users = { "etl-bot" = { permission = "push" } }
teams = { "data-eng" = { permission = "maintain" } }
}
}
}
module "collab_fleet" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
for_each = local.repo_access
repository = each.key
users = each.value.users
teams = each.value.teams
}
⚠️ A large fleet issues many collaborator/invitation API calls — see Troubleshooting on secondary rate limits.
9 · Empty collaborator set — repository with no extra collaborators
module "collab_empty" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "internal-tooling"
# users and teams default to {} — declares "no collaborators beyond org defaults".
}ℹ️ This authoritatively asserts an empty set: any user/team attached out-of-band (and not in
ignore_teams) is removed.
10 · Personal (non-org) repository — `push` only
module "collab_personal" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = "my-personal-tool"
users = {
"trusted-friend" = { permission = "push" } # only valid non-owner permission on personal repos
}
}11 · Cross-module wiring — consume `terraform-github-repository`
module "repository" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"
name = "platform-api"
visibility = "private"
}
module "collaborators" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = module.repository.name # wire by name (== id)
teams = {
"platform-engineering" = { permission = "maintain" }
}
}12 · End-to-end — repository + collaborators + ruleset
module "repository" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository?ref=v1.0.0"
name = "regulated-loan-service"
visibility = "private"
vulnerability_alerts = true
}
module "collaborators" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-collaborators?ref=v1.0.0"
repository = module.repository.name
teams = {
"platform-engineering" = { permission = "maintain" }
"security-reviewers" = { permission = "triage" }
}
ignore_teams = ["compliance-auditors"]
}
module "ruleset" {
source = "git::https://github.com/microsoftexpert/terraform-github-repository-ruleset?ref=v1.0.0"
repository = module.repository.name
enforcement = "active"
}13 · Importing an existing collaborator set
# id is the repository name
terraform import 'module.collaborators.github_repository_collaborators.this' platform-api
⚠️ On the first plan after import, every collaborator NOT declared inusers/teams(and not inignore_teams) shows as a removal — review carefully before applying.
Identity
repository(string, required) — the repository name only (notowner/repo); the owner is a provider concern. ForceNew.
Collaborators
users(map(object), default{}) — keyed by GitHub username; each{ permission = optional(string, "pull") }. Authoritative.teams(map(object), default{}) — keyed by team ID or slug; each{ permission = optional(string, "pull") }. Authoritative.
Coexistence
ignore_teams(set(string), default[]) — team IDs/slugs to exclude from authoritative removal.
permission accepts pull | triage | push | maintain | admin (validated). No tags, no timeouts, no owner/token inputs.
| Output | Description |
|---|---|
id |
Resource ID — the repository name. Primary cross-module reference. |
repository |
The repository name whose collaborator set is managed (passthrough for downstream wiring). |
repository_id |
Numeric ID of the repository. |
invitation_ids |
Map of username ⇒ invitation ID for users with pending (unaccepted) invitations. Empty once all are accepted. |
ℹ️ No
sensitiveoutputs — this resource exposes no secret material.
- Least privilege by default —
permissiondefaults topull, overriding the provider'spushdefault;admin/maintainrequire explicit opt-in. - Authoritative = drift-free — the complete collaborator set lives in state; out-of-band access is reconciled away.
- Explicit coexistence — externally-managed teams are exempted deliberately via
ignore_teams, never silently. - Closed-set validation — enum and non-empty-key validation fail fast at plan time.
- Provider owns auth & owner — no
owner/token/app_authinputs; nothing secret crosses the module boundary. - No tags / timeouts tail — GitHub has neither; the module surface stays minimal.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Always pin the source to a tag (?ref=v1.0.0) — never a branch. Branch sources drift silently between applies.
| Symptom | Cause | Resolution |
|---|---|---|
| A team reappears as a removal on every plan | The team is attached out-of-band (SSO sync / enterprise automation) but not declared | Add it to teams, or list it in ignore_teams to exempt it from authoritative reconcile. |
| A user keeps getting removed | Authoritative management strips undeclared users — there is no ignore_user |
Add the user to users. |
403 / You have exceeded a secondary rate limit on bulk apply |
Many serial collaborator/invitation API calls from a large users/teams map |
Apply in smaller batches, lower terraform apply -parallelism, or let Terraform's backoff retry pace the run. |
permission must be one of: pull, triage, push, maintain, admin |
A custom repository role (Enterprise Cloud) or a typo | Use a built-in role, or relax the module's validation {} block to allow your custom role name. |
404 Not Found on apply |
repository name wrong, or the provider owner/token can't see/admin the repo |
Verify the repo name (name only, no owner prefix) and that the identity has admin on the repo. |
| Plan wants to destroy/recreate the whole resource | repository changed (it's ForceNew — the id) |
Expected on a repo rename; confirm the recreate is intended. |
Invitation never clears from invitation_ids |
The invited user hasn't accepted | The entry clears automatically on acceptance; resend/cancel via GitHub if stale. |
Personal-repo apply rejects pull/triage/etc. |
Personal repos only support push for non-owners |
Use permission = "push", or move the repo into an org. |
integrations/githubprovider —github_repository_collaboratorsresource reference- Sibling modules: terraform-github-repository (keystone), terraform-github-repository-ruleset, terraform-github-branch-protection, terraform-github-team
- GitHub docs — repository roles & permission levels; managing team access to repositories
- GitHub docs — REST API secondary rate limits