Manages a single user's membership in a GitHub organization — invites the user, sets their
member/adminrole, and controls whether destroy fully off-boards or merely downgrades. Secure-by-default (role = "member", full removal on destroy). Built for integrations/github v6.x.
- 👤 Wraps one
github_membershipresource namedthis— one module instance per managed user. - 🎭 Sets the organization role:
member(least privilege, the default) oradmin(maps to "Owner" in the GitHub UI). - 📩 Applying sends an organization invitation; the membership stays
pendinguntil the user accepts, then flips toactive. - 🧹
downgrade_on_destroychooses the off-boarding behaviour — fully remove the user (default), or relinquish management by downgrading them tomember. - 🏢 The target organization is a provider concern (
owner/GITHUB_OWNER) — never a module variable. The membershipidis composed as<organization>:<username>.
Why it matters: organization owners are the highest-privilege identity in the entire source supply chain. Defaulting every membership to
memberand to full removal on destroy means owner access is always a deliberate, reviewable act — never an accident of copy-paste.
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!
This module establishes org identity — it consumes nothing from sibling modules (the target org is a provider concern) and its managed username is the input other modules wire in to place that same login on a team or grant it repo access.
flowchart LR
membership["tf-mod-github-membership<br/>(THIS MODULE)"]
team["tf-mod-github-team"]
collab["tf-mod-github-repository-collaborators"]
membership -->|"username"| team
membership -->|"username"| collab
style membership fill:#8957E5,color:#fff
This module consumes nothing from sibling modules (the organization is supplied by the provider's owner / GITHUB_OWNER); it emits username — consumed by tf-mod-github-team (team membership) and tf-mod-github-repository-collaborators (collaborator grants) — plus id / role / etag for governance audits — see the Typical wiring section.
A single, flat organization membership — every argument is a scalar, so there are no nested or dynamic blocks.
flowchart TD
this["github_membership.this<br/>(keystone)<br/>one user's org membership + role"]
style this fill:#8957E5,color:#fff
tf-mod-github-membership/
├── providers.tf # terraform >= 1.12.0; integrations/github ~> 6.0
├── variables.tf # username, role, downgrade_on_destroy
├── main.tf # github_membership.this
├── outputs.tf # id, username, role, etag
├── SCOPE.md # resource scope, token scopes, prerequisites, emits
└── README.md # this file
module "octocat_membership" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "octocat"
# role defaults to "member"; downgrade_on_destroy defaults to false
}ℹ️ The organization is not passed here — it comes from the provider's
owner/GITHUB_OWNER. Every instance manages the user inside whatever org the caller's provider targets.
Every output in outputs.tf gets a row. This is an org-governance module operating at organization scope — it consumes no repository and is most often referenced by other org-scope modules or for downstream auditing.
| Output | Type | Typically consumed by |
|---|---|---|
id |
string (<org>:<username>) |
Org-governance audits / for_each keys; any module keying off the composed membership ID |
username |
string |
tf-mod-github-team (membership), tf-mod-github-repository-collaborators — wiring the same login to team/repo access |
role |
string |
Governance reporting / conditional logic that branches on owner vs member |
etag |
string |
Drift detection / change-tracking tooling |
idformat —<organization>:<username>. The membership ID is the org login joined to the user login by a colon (e.g.FinancialPartners:octocat). Because the org half comes from the provider, the sameusernameproduces different IDs against different orgs. This is the import ID too:terraform import module.x.github_membership.this FinancialPartners:octocat.- No
node_id/repo_id/slughere. Those exist on repository- and team-shaped resources.github_membershipexposes onlyid,username,role,etag, and the read-onlystate(active/pending) — this module surfaces the first four as outputs. usernameis the resource key but is not ForceNew. Changingusernamere-targets the membership to a different user via update; the prior user is left in place and must be managed by their own resource instance. Manage one user per module instance — drive many viafor_eachover a map (see Example Library).roleis in-place mutable. Flippingmember↔adminis an update, not a replacement — no invitation churn.- Invitations are asynchronous. A fresh apply leaves the membership
pendinguntil the human accepts the email/UI invite. Terraform considers the resource created once the invite is sent —state = pendingis normal and not drift. - Authoritative-per-user, not org-wide.
github_membershipis authoritative for one user's membership, not the org's entire member list. It will not remove users it does not manage. To declaratively own the full org roster you would layer ongithub_membershipinstances per user (afor_eachmap) and accept that unmanaged users persist — there is no single "all members" authoritative resource in this module's scope. - Destroy semantics hinge on
downgrade_on_destroy.false(default) removes the user / cancels the pending invite — clean off-boarding.truekeeps the user in the org and downgrades them tomember— use only when Terraform should stop managing an owner without ejecting them. - Org membership ≠ rulesets/branch protection. This module governs who is in the org and at what role; it does not govern repository protections. (prefers
github_repository_ruleset/github_organization_rulesetover legacy branch protection — but that is a different module entirely.)
1 · Minimal — standard member (secure default)
module "member" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "octocat"
}2 · Explicit member role
module "analyst" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "data-analyst-jane"
role = "member"
}3 · Organization owner (admin) — grant deliberately
module "platform_owner" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "platform-lead"
role = "admin" # maps to "Owner" in the GitHub UI — full administrative control
}4 · Downgrade-on-destroy — relinquish management without ejecting
module "departing_owner" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "founding-admin"
role = "admin"
downgrade_on_destroy = true # on destroy: keep the user, downgrade to "member" (do not remove)
}5 · Full off-boarding (explicit default)
module "contractor" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "temp-contractor"
role = "member"
downgrade_on_destroy = false # on destroy: fully remove from the org / cancel pending invite
}6 · `for_each` at scale — a roster of members from a map(object)
variable "members" {
type = map(object({
username = string
role = optional(string, "member")
downgrade_on_destroy = optional(bool, false)
}))
default = {
jane = { username = "jane-doe" }
raj = { username = "raj-patel", role = "member" }
lee = { username = "lee-admin", role = "admin" }
}
}
module "org_members" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
for_each = var.members
username = each.value.username
role = each.value.role
downgrade_on_destroy = each.value.downgrade_on_destroy
}7 · Split rosters — owners vs members keyed separately
locals {
owners = toset(["platform-lead", "security-lead"])
members = toset(["dev-a", "dev-b", "dev-c"])
}
module "owners" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
for_each = local.owners
username = each.key
role = "admin"
}
module "members" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
for_each = local.members
username = each.key # role defaults to "member"
}8 · Promote an existing member to owner — in-place update
# Change only `role` on an existing instance; Terraform updates, not replaces —
# no new invitation is sent.
module "promoted" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "rising-star"
role = "admin" # was "member"
}9 · Driven from a CSV / external data source
locals {
roster = { for r in csvdecode(file("${path.module}/members.csv")) : r.login => r }
}
module "from_csv" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
for_each = local.roster
username = each.value.login
role = each.value.role
}10 · Conditional admin via a toggle
variable "break_glass_admin" { type = bool, default = false }
module "break_glass" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "incident-responder"
role = var.break_glass_admin ? "admin": "member"
}11 · Cross-module wiring — same login into a team
module "developer" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "octocat"
}
# A sibling team module consumes the managed login, guaranteeing the user is an
# org member before being placed on a team.
module "platform_team" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-team?ref=v1.0.0"
name = "platform"
members = {
octocat = { username = module.developer.username, role = "member" }
}
}12 · Auditing output — expose the composed membership IDs
module "org_members" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
for_each = var.members
username = each.value.username
role = each.value.role
}
output "membership_ids" {
description = "Map of key => <org>:<username> for compliance reporting."
value = { for k, m in module.org_members : k => m.id }
}
output "org_owners" {
description = "Logins currently holding the admin (Owner) role."
value = [for m in module.org_members : m.username if m.role == "admin"]
}13 · Importing an existing membership
module "existing" {
source = "git::https://github.com/microsoftexpert/tf-mod-github-membership?ref=v1.0.0"
username = "already-a-member"
role = "member"
}
# Then, once: terraform import 'module.existing.github_membership.this' FinancialPartners:already-a-member- Identity
username(string, required) — the GitHub login to add to the org. Resource key; not ForceNew. Validated non-empty.- Role
role(string, default"member") —memberoradmin. Validated against the closed set;admin= org Owner.- Lifecycle
downgrade_on_destroy(bool, defaultfalse) —falseremoves the user on destroy;truekeeps them and downgrades tomember.
| Output | Description |
|---|---|
id |
Membership ID, composed as <organization>:<username>. Primary cross-module reference and import ID. |
username |
The GitHub login of the managed organization member. |
role |
The organization role in effect (member or admin). |
etag |
Entity tag (ETag) representing the current state of the membership object. |
- 🔒 Least privilege by default —
roledefaults tomember;admin(Owner) must be requested explicitly and is visible in every plan. - 🧹 Clean off-boarding by default —
downgrade_on_destroy = falsemeans destroy actually removes access rather than silently leaving an org member behind. - 🎯 Single responsibility — one user, one resource, one module instance. Scale via
for_each, never by widening the module's scope. - 🏢 Org is a provider concern — no
owner/token/app_authvariable. The module is portable across orgs by swapping the provider config. - ✅ Validated closed sets —
roleis constrained tomember/admin;usernamemust be non-empty. Invalid input fails at plan, not apply.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Pin the version. Always source at?ref=v1.0.0— never a branch. Branch sources drift silently and break reproducibility.
403 Resource not accessible/Must have admin rights to Organizationon apply. The provider identity lacks org-admin authority. Classic PAT needs theadmin:orgscope; a fine-grained token / GitHub App needs organization Members: Read & write. See Required token scopes inSCOPE.md.- Membership stays
pendingforever. Normal until the invited user accepts via email/GitHub UI. Terraform sent the invite; acceptance is a human action and is not drift. usernamechange replaced the wrong thing. Changingusernamere-targets to a different user (update, not destroy-of-old-user). The previously managed user is left in the org. Manage one user per instance; usefor_eachfor rosters.- Destroy left the user in the org. Check
downgrade_on_destroy— iftrue, the user is intentionally kept and downgraded tomemberrather than removed. - Secondary rate limits on bulk apply. Large
for_eachrosters can trip GitHub's secondary (abuse) rate limits when many invitations fire at once. Apply in smaller batches or accept the provider's automatic back-off/retry; spread very large onboardings across runs. Cannot add user; not a member-style errors. The user login may not exist or may already be a member via another path. Verify the login and whether an existing membership should be imported instead (terraform import... <org>:<username>).
- integrations/github provider —
github_membershipresource reference - module —
tf-mod-github-team(consumes the managed login for team placement) - module —
tf-mod-github-organization-settings(org-level 2FA / default member permission) - module —
tf-mod-github-organization-roles(Enterprise-only custom org roles)