Skip to content

microsoftexpert/tf-mod-github-membership

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🐙 GitHub Organization Membership Terraform Module

Manages a single user's membership in a GitHub organization — invites the user, sets their member/admin role, 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.

Terraform GitHub provider module type resources


🧩 Overview

  • 👤 Wraps one github_membership resource named this — one module instance per managed user.
  • 🎭 Sets the organization role: member (least privilege, the default) or admin (maps to "Owner" in the GitHub UI).
  • 📩 Applying sends an organization invitation; the membership stays pending until the user accepts, then flips to active.
  • 🧹 downgrade_on_destroy chooses the off-boarding behaviour — fully remove the user (default), or relinquish management by downgrading them to member.
  • 🏢 The target organization is a provider concern (owner / GITHUB_OWNER) — never a module variable. The membership id is composed as <organization>:<username>.

Why it matters: organization owners are the highest-privilege identity in the entire source supply chain. Defaulting every membership to member and to full removal on destroy means owner access is always a deliberate, reviewable act — never an accident of copy-paste.


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

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
Loading

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.


🧬 What this module builds

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
Loading

📁 Module Structure

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

⚙️ Quick Start

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.

🔌 Typical wiring

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

🧠 Architecture Notes

  • id format — <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 same username produces 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 / slug here. Those exist on repository- and team-shaped resources. github_membership exposes only id, username, role, etag, and the read-only state (active / pending) — this module surfaces the first four as outputs.
  • username is the resource key but is not ForceNew. Changing username re-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 via for_each over a map (see Example Library).
  • role is in-place mutable. Flipping memberadmin is an update, not a replacement — no invitation churn.
  • Invitations are asynchronous. A fresh apply leaves the membership pending until the human accepts the email/UI invite. Terraform considers the resource created once the invite is sent — state = pending is normal and not drift.
  • Authoritative-per-user, not org-wide. github_membership is 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 on github_membership instances per user (a for_each map) 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. true keeps the user in the org and downgrades them to member — 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_ruleset over legacy branch protection — but that is a different module entirely.)

📚 Example Library

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

📦 Inputs (high-level)

  • Identity
  • username (string, required) — the GitHub login to add to the org. Resource key; not ForceNew. Validated non-empty.
  • Role
  • role (string, default "member")member or admin. Validated against the closed set; admin = org Owner.
  • Lifecycle
  • downgrade_on_destroy (bool, default false)false removes the user on destroy; true keeps them and downgrades to member.

🧾 Outputs

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.

🧱 Design Principles

  • 🔒 Least privilege by defaultrole defaults to member; admin (Owner) must be requested explicitly and is visible in every plan.
  • 🧹 Clean off-boarding by defaultdowngrade_on_destroy = false means 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_auth variable. The module is portable across orgs by swapping the provider config.
  • Validated closed setsrole is constrained to member/admin; username must be non-empty. Invalid input fails at plan, not apply.

🚀 Runbook

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.

🔍 Troubleshooting

  • 403 Resource not accessible / Must have admin rights to Organization on apply. The provider identity lacks org-admin authority. Classic PAT needs the admin:org scope; a fine-grained token / GitHub App needs organization Members: Read & write. See Required token scopes in SCOPE.md.
  • Membership stays pending forever. Normal until the invited user accepts via email/GitHub UI. Terraform sent the invite; acceptance is a human action and is not drift.
  • username change replaced the wrong thing. Changing username re-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; use for_each for rosters.
  • Destroy left the user in the org. Check downgrade_on_destroy — if true, the user is intentionally kept and downgraded to member rather than removed.
  • Secondary rate limits on bulk apply. Large for_each rosters 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>).

🔗 Related Docs

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

About

No description or website provided.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages