Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Repository Collaborators Terraform Module

Authoritative, least-privilege collaborator management for a single GitHub repository — declares the complete set of user and team collaborators (plus an ignore_team escape hatch for externally-managed teams) behind one clean module boundary. Built for integrations/github v6.x.

Terraform GitHub provider module type resources


🧩 Overview

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 optional permission.
  • 🏷️ Team collaborators — a map(object) keyed by team ID or slug, each with an optional permission.
  • 🛡️ Least-privilege defaults — every collaborator defaults to pull (the provider's native default is push), 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_team escape 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_ids output surfaces pending (unaccepted) user invitations.
  • Closed-set validationpermission is validated against pull | 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.


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

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.


🧬 What this module builds

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
Loading

📁 Module Structure

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

⚙️ Quick Start

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" }
  }
}

⚠️ The owner/organization and authentication (PAT / GitHub App) are provider concerns — configure them on the caller's provider "github" block, never as module inputs.


🔌 Typical wiring

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

🧠 Architecture Notes

  • id is the repository name. github_repository_collaborators uses the repository name as its resource id (and output.id returns exactly that). There is no node-id form for this resource — it manages an association set, not a first-class GitHub object. repository_id is the numeric repo ID for callers that need it; there is no node_id/slug/html_url here because the resource has none.
  • repository is ForceNew (immutable). The resource id is the repository name, so changing var.repository targets 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 from users/teams (and not in ignore_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 in teams or exempted via ignore_teams, or it will be churned on every apply.
  • ignore_teams is 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 in ignore_teams (renders one ignore_team block each) to leave them alone. There is no equivalent ignore_user — externally-managed user collaborators must be added to users or 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's validation {} — relax it if you depend on them. On a personal (non-org) repository the only valid non-owner permission is push.
  • 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_value vs encrypted_value consideration and no sensitive inputs/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 legacy github_branch_protection v3 API) for branch/push rules.
  • Secondary rate limits on bulk for_each. Large users/teams maps issue many serial collaborator/invitation API calls and can trip GitHub's secondary rate limit (HTTP 403 with a Retry-After). For big sets, apply in batches, set parallelism low, or expect Terraform's automatic retry/backoff to pace the run.

📚 Example Library

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 in users/teams (and not in ignore_teams) shows as a removal — review carefully before applying.


📦 Inputs (high-level)

Identity

  • repository (string, required) — the repository name only (not owner/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.


🧾 Outputs

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 sensitive outputs — this resource exposes no secret material.


🧱 Design Principles

  • Least privilege by defaultpermission defaults to pull, overriding the provider's push default; admin/maintain require 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_auth inputs; nothing secret crosses the module boundary.
  • No tags / timeouts tail — GitHub has neither; the module surface stays minimal.

🚀 Runbook

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.


🔍 Troubleshooting

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.

🔗 Related Docs

  • integrations/github provider — github_repository_collaborators resource 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

Releases

Packages

Contributors

Languages