こんにちは。技術戦略チームの菅原です。
Faber Company 開発チームでは、GitHub Organization のリポジトリやアクセス権の管理に Terraform を導入しました。これにより、コードとして GitHub の設定を管理できるようになり、変更の追跡や再現性の向上が期待できます。
この記事では、導入に至るまでの背景や、実際の設定例、運用上のポイントなどを共有します。
背景
Faber Company では、ほぼすべてのプロジェクトが GitHub Organization 上で管理されています。
以前まではリポジトリの作成やアクセス権の設定などはすべて手動で行っていました。
リポジトリ数は130超におよび、管理が煩雑になっていたほか、同じ設定を複数のリポジトリに適用する際の手間が課題となっていました。
アクセス権の変更などの重要な設定の場合、ISMS 対応のために変更者が Notion に履歴を記録する運用が行われており、これも手間がかかる上に、記録漏れのリスクもありました。
- 管理の手間を減らすこと
- 同じ変更を機械的に適用できるようにすること
- 変更履歴を自動的に記録できるようにすること
の3点を目的として、GitHub Organization の管理に Terraform を導入することにしました。
構成
Terraform の GitHub Provider を使用して、GitHub Organization のリポジトリやアクセス権の設定をコード化しています。
GitHub API を都度呼び出す都合、Plan/Apply にかなり時間がかかるため、時間の短縮を狙って責任ごとに別モジュールとし、並列して Plan を実行できるようにしました。
既存リソースのインポートは、Claude Code に gh CLI を使わせて各モジュール内のコードと terraform import 用のシェルスクリプトを生成し、それを人間が確認した後実行することで行いました。
github/
├── backend.hcl # 共通のバックエンド定義(AWS S3)が入っています
├── FaberTechnology
│ ├── people
│ ├── permissions
│ ├── repos
│ └── teams
└── README.md
People モジュール
Organization のメンバーを管理するモジュールです。github_membership リソースを使用しています。
locals {
people = {
AdminUserId = "admin",
MemberUserId = "member",
AnotherMemberUserId = "member",
}
}
resource "github_membership" "member" {
for_each = local.people
username = each.key
role = each.value
}
Teams モジュール
Organization のチームを管理するモジュールです。github_team リソースを使用しています。
既存のチームに親子構造があったため、親チームと子チームを分けて管理するようにしています。また、チームのメンバーも同じ YAML ファイルから管理するようにしています。さらにネストがある場合は、最も深い構造に合わせて teams.tf を書き換える必要があります。
実動コードでは親・子・孫の3階層の構造になっていますが、ここでは簡略化して親・子の2階層の構造で例を示します。
teams/
├── main.tf
├── teams.tf
└── teams.yaml
locals {
teams_yaml = yamldecode(file("${path.module}/teams.yaml"))
root_teams = {
for key, data in local.teams_yaml :
key => data if data.parent_team == null
}
children = {
for key, data in local.teams_yaml :
key => data if data.parent_team != null && lookup(local.root_teams, data.parent_team, null) != null
}
team_members = merge([
for team_key, team_data in local.teams_yaml :
merge([
for member_item in team_data.members : {
for username, role in member_item :
"${team_key}_${username}" => {
team = team_key
username = username
role = role
}
}
]...)
if can(team_data.members) && length(team_data.members) > 0
]...)
}
resource "github_team" "root_teams" {
for_each = local.root_teams
name = each.key
description = each.value.description
privacy = each.value.privacy
}
resource "github_team" "children" {
for_each = local.children
name = each.key
description = each.value.description
privacy = each.value.privacy
parent_team_id = github_team.root_teams[each.value.parent_team].id
depends_on = [github_team.root_teams]
}
resource "github_team_membership" "members" {
for_each = local.team_members
team_id = (
contains(keys(local.root_teams), each.value.team) ? github_team.root_teams[each.value.team].id :
github_team.children[each.value.team].id
)
username = each.value.username
role = each.value.role
depends_on = [github_team.root_teams, github_team.children]
}
team1:
description: "チーム1の説明"
privacy: "closed"
parent_team: null
members:
- AdminUserId: "maintainer"
- MemberUserId: "member"
team2:
description: "チーム2の説明"
privacy: "closed"
parent_team: "team1"
members:
- AnotherMemberUserId: "maintainer"
Repos モジュール
リポジトリを管理するモジュールです。github_repository リソースを使用しています。
repos
├── main.tf
├── moved.tf # リポジトリ名を変更するときに使う `moved` ブロックが入っています
├── repos.tf
└── repos.yaml
locals {
repos_yaml = yamldecode(file("${path.module}/repos.yaml"))
}
resource "github_repository" "repos" {
for_each = local.repos_yaml
name = each.key
description = lookup(each.value, "description", "")
visibility = lookup(each.value, "visibility", "private")
homepage_url = lookup(each.value, "homepage_url", null)
has_issues = lookup(each.value, "has_issues", true)
has_wiki = lookup(each.value, "has_wiki", false)
has_projects = lookup(each.value, "has_projects", false)
has_downloads = lookup(each.value, "has_downloads", true)
has_discussions = lookup(each.value, "has_discussions", false)
archived = lookup(each.value, "archived", false)
archive_on_destroy = lookup(each.value, "archive_on_destroy", true)
vulnerability_alerts = lookup(each.value, "vulnerability_alerts", true)
delete_branch_on_merge = lookup(each.value, "delete_branch_on_merge", true)
}
repo1:
description: "リポジトリ1の説明"
visibility: "private"
homepage_url: "https://example.com/repo1"
Permissions モジュール
ユーザーやチームがリポジトリに対して持つアクセス権を管理するモジュールです。github_repository_collaborators リソースを使用しています。
permissions/
├── main.tf
├── permissions.tf
└── permissions.yaml
locals {
permissions = yamldecode(file("${path.module}/permissions.yaml"))
}
resource "github_repository_collaborators" "collaborators" {
for_each = local.permissions
repository = each.key
dynamic "user" {
for_each = lookup(each.value, "users", {})
content {
username = user.key
permission = user.value
}
}
dynamic "team" {
for_each = lookup(each.value, "teams", {})
content {
team_id = team.key
permission = team.value
}
}
ignore_team {
team_id = "owners"
}
}
repo1:
users:
MemberUserId: "push"
teams:
team1: "maintain"
CI
GitHub Actions を使用して、Plan/Apply を実行しています。実際の運用は次のようなフローです。
- 変更内容を
*.tf や *.yaml に記述して Pull Request を作成
- CI が Plan を実行して、変更内容を確認、PR にコメント
- 問題なければ PR をマージして、CI が Apply を実行
また、毎日定期的に Plan を実行して、GitHub 上の設定とコードの状態に乖離(ドリフト)がないかを確認する運用も行っています。ドリフトが検出された場合は、その内容を含む Issue が自動的に作成され、修正を促します。
これによって、Terraform の知識がない人でも yaml を編集するだけで GitHub の設定変更をリクエストできるようになり、変更内容も自動的に記録されるようになりました。
実際のユースケース
新しいユーザーを Organization に招待する
新しいユーザーを Organization に招待する場合は、people/people.tf の locals.people にユーザー名とロールを追加して Pull Request を作成します。
locals {
people = {
AdminUserId = "admin",
MemberUserId = "member",
AnotherMemberUserId = "member",
+ NewMemberUserId = "member",
}
}
Organization のシート数が足りているか確認した後、問題なければマージして Apply を実行します。これで新しいユーザーが Organization に招待されます。
既存のリポジトリに新しいチームを追加する
既存のリポジトリに新しいチームを追加する場合は、まず teams/teams.yaml に新しいチームとそのメンバーを追加します。
new_team:
description: "新しいチームの説明"
privacy: "closed"
members:
- NewMemberUserId: "maintainer"
次に、permissions/permissions.yaml に新しいチームのアクセス権を追加します。
repo1:
users:
MemberUserId: "push"
teams:
team1: "maintain"
+ new_team: "push"
これで新しいチームが作成され、既存のリポジトリに対してアクセス権が付与されます。
リポジトリをアーカイブする
リポジトリをアーカイブする場合は、repos/repos.yaml の該当リポジトリの設定に archived: true を追加します。
repo1:
description: "リポジトリ1の説明"
+ archived: true
その他の方法として、リポジトリ設定のデフォルトは archive_on_destroy: true となっているため、リポジトリのエントリごと削除された場合も自動的にアーカイブされるようになっています。
-repo1:
- description: "リポジトリ1の説明"
運用してみて良かった点
GitHub Organization の管理に Terraform を導入してみて、以下のような良かった点がありました。
変更内容をコミット履歴として追跡できるようになった
PR のテンプレートにその変更が必要になった Slack スレッドなどを残すように運用しているため、誰がどんな理由で設定変更を必要としていたのか追跡できるようになりました。
不要なリポジトリやチームの棚卸しができるようになった
アーカイブしたリポジトリに残っている権限や、参照されていないチームなどを、Claude Code や GitHub Copilot が確認できるようになりました。人力でアクセス権を洗い出すことなく不要なリソースを削除する PR が自動生成できるようになったので、棚卸しがかなり楽になりました。
設定変更によって起こることをエージェントが予測できるようになった
「このチームからAさんを削除するとどのリポジトリにアクセスできなくなる?」などの質問に対して、Claude Code や GitHub Copilot がコードを読んで回答できるようになりました。これによって、設定変更の影響範囲を事前に把握しやすくなりました。
今後の課題
Apply の順序依存問題
リポジトリのアクセス権を管理する Permissions モジュールは、People モジュールや Teams モジュールで管理しているユーザーやチームに依存しています。そのため、これらのモジュールの変更内容によっては、Apply の順序によってエラーが発生する可能性があります。
現在は Apply を並列に実行しており、エラーが出たら失敗したモジュールを手動で再実行する運用ですが、今後はモジュール間の依存関係を明示的に管理して、Apply の順序を自動的に制御できるようにすることを検討しています。
オンボーディング・オフボーディング用ワークフローの整備
現在は、オンボーディングやオフボーディングの際に必要な変更を手動でコードに反映させる運用ですが、今後はこれらのワークフローを自動化することも検討しています。
例えば、オンボーディングの際には新しいユーザーを People モジュールに追加し、必要なチームやリポジトリへのアクセス権を自動的に付与するようなワークフローを構築することが考えられます。
オフボーディングの際には、ユーザーを People モジュールから削除し、関連するチームやリポジトリへのアクセス権を自動的に削除するようなワークフローも同様に検討しています。
その他のリソースの取り込み
アカウント管理が必要なのは GitHub だけではありません。今後は、AWS IAM や Google Cloud の IAM などのクラウドプロバイダのリソースも同様に Terraform で管理することを検討しています。
複数のサービスにまたがるアクセス権の管理を一元化し、より効率的な運用が可能になると考えています。