GitHub の権限管理を Terraform 化し、state を Cloudflare R2 に置く
背景
GitHub のチームと権限の変更依頼を Pull Request で受けたくて、Terraform で管理することにしました。 管理するのはチーム、チームへの所属、チームからリポジトリへの権限の 3 つです。
リポジトリの構成
権限管理だけを置く専用のリポジトリにします。
.
├── versions.tf # Terraform と github provider のバージョン
├── provider.tf # provider "github" の設定
├── backend.tf # backend "s3" {}(接続情報は backend.hcl から渡す)
├── backend.hcl # R2 の接続情報(gitignore)
├── teams.tf # チームとメンバー
├── repositories.tf # チームからリポジトリへの権限
└── .github/workflows/terraform.yml
チームとメンバーの定義
親チームの engineering の下に、platform と product の 2 つのチームを置く例で、teams.tf の全文です。
チームとメンバーは locals に書き、for_each でリソースに展開します。
locals {
# チームの説明に付ける Terraform 管理の注記
managed_note = "Managed by Terraform"
# 説明が空なら注記だけを設定し、値があれば説明と連結する
managed_desc = {
engineering = local.engineering.description != "" ? "${local.engineering.description} — ${local.managed_note}" : local.managed_note
sub = {
for slug, t in local.sub_teams :
slug => t.description != "" ? "${t.description} — ${local.managed_note}" : local.managed_note
}
}
# 親チームの members には、親チームへ直接所属させるメンバーだけを書く
engineering = {
description = "Engineering team"
privacy = "closed"
notification_setting = "notifications_disabled"
members = [
{ username = "alice", role = "maintainer" },
]
}
# 親チーム配下のサブチーム。key は Terraform 内で参照する識別子として利用する
sub_teams = {
platform = {
name = "platform"
description = "Platform team"
members = [
{ username = "alice", role = "maintainer" },
{ username = "bob", role = "member" },
]
}
product = {
name = "product"
description = "Product team"
members = []
}
}
# 親チームの membership。key は username
engineering_memberships = { for m in local.engineering.members : m.username => m }
# サブチームの members を membership 用の map に展開する
sub_team_memberships = {
for m in flatten([
for slug, t in local.sub_teams : [
for mem in t.members : { team = slug, username = mem.username, role = mem.role }
]
]) : "${m.team}:${m.username}" => m
}
}
# 親チーム
resource "github_team" "engineering" {
name = "engineering"
description = local.managed_desc.engineering
privacy = local.engineering.privacy
notification_setting = local.engineering.notification_setting
}
# サブチーム。parent_team_id で親に紐付ける
resource "github_team" "engineering_sub" {
for_each = local.sub_teams
name = each.value.name
description = local.managed_desc.sub[each.key]
privacy = "closed"
notification_setting = "notifications_enabled"
parent_team_id = github_team.engineering.id
}
resource "github_team_membership" "engineering" {
for_each = local.engineering_memberships
team_id = github_team.engineering.id
username = each.value.username
role = each.value.role
}
resource "github_team_membership" "engineering_sub" {
for_each = local.sub_team_memberships
team_id = github_team.engineering_sub[each.value.team].id
username = each.value.username
role = each.value.role
}
メンバーを加える依頼は、members に 1 行足す Pull Request になります。
members = [
{ username = "alice", role = "maintainer" },
{ username = "bob", role = "member" },
+ { username = "carol", role = "member" },
]
リポジトリ権限の定義
チームごとに、アクセスできるリポジトリと権限を書きます。
github_team_repository の permission に書く名前は、GitHub の画面表記と違います。
| Terraform | GitHub の画面 |
|---|---|
pull | Read |
triage | Triage |
push | Write |
maintain | Maintain |
admin | Admin |
# チームごとに、アクセスを許可するリポジトリと権限を定義する。
#
# Terraform 上の権限名は GitHub の画面表記と異なる。
# pull = Read / triage = Triage / push = Write / maintain = Maintain / admin = Admin
#
# ネストしたチームでは、親に付けた権限が子に継承される。
# 子チーム側で親から継承した権限を下げることはできないため、
# 親には権限を書かない。
locals {
repository_permissions = {
platform = {
"app" = "push"
"shared-config" = "maintain"
}
product = {
"app" = "triage"
}
}
}
resource "github_team_repository" "platform" {
for_each = local.repository_permissions.platform
team_id = github_team.engineering_sub["platform"].id
repository = each.key
permission = each.value
}
resource "github_team_repository" "product" {
for_each = local.repository_permissions.product
team_id = github_team.engineering_sub["product"].id
repository = each.key
permission = each.value
}
親チームに権限を書かない理由は、次の継承の節で説明します。
state が結びつけるもの
R2 でロックを取る
R2 は S3 互換なので、s3 バックエンドの接続先を R2 に向ければ使えます。
ロックは以前 DynamoDB が必要でしたが、Terraform 1.10 の use_lockfile なら .tflock というオブジェクトで代わりに取れます。
S3 の条件付き書き込み(If-None-Match)を使う仕組みで、R2 も対応しています。
接続情報は backend.hcl に書きます。
bucket = "terraform-state"
key = "github-permissions/terraform.tfstate"
region = "auto"
endpoints = {
s3 = "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true
use_path_style = true
use_lockfile = true
skip_ で始まるフラグは、AWS 固有の処理を無効にします。
インスタンスメタデータからの認証情報の取得や、アカウント ID の問い合わせがそれです。
skip_s3_checksum は、アップロード時にチェックサムのヘッダーを送らない設定です。
S3 互換 API の一部がこのヘッダーに対応していないためのもので、Cloudflare の公式ドキュメントにある R2 向けの設定例にも入っています。
認証には R2 の API トークンで発行した Access Key ID と Secret Access Key を使います。
環境変数の名前は AWS 用の AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY のままです。
S3 互換なので、バックエンドがその名前で読みます。
親子チームと権限の継承
子チームのメンバーは親チームのメンバーでもあるので、親に付けた権限は子にも効きます。 子で権限を上げることはできますが、継承した権限を下げることはできません。
github_team_repository が管理するのは、コードに書いた権限だけです。
継承された分は plan に出てきません。
親に権限を置くと、子チームの実際の権限がコードから読めなくなります。
権限は子チームにだけ書いて、親チームは所属をまとめる単位にします。 そうすればコードと実際の権限が一致します。
CI と認証
GitHub App に与える権限は次の 2 つです。 Metadata は自動で Read になります。
| 権限 | 用途 | アクセス |
|---|---|---|
| Organization permissions の Members | チームとメンバーの管理 | Read and write |
| Repository permissions の Administration | チームとリポジトリの関連付け | Read and write |
ワークフローは App のトークンを発行して GITHUB_TOKEN に渡し、R2 の認証情報とあわせて Terraform を実行します。
apply は apply を true にして手動で実行したときだけ走ります。
name: terraform
on:
workflow_dispatch:
inputs:
apply:
description: "apply まで実行する(false なら plan のみ)"
type: boolean
default: false
# apply を直列に実行する
concurrency:
group: terraform-apply
cancel-in-progress: false
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.TERRAFORM_APP_CLIENT_ID }}
private-key: ${{ secrets.TERRAFORM_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- uses: hashicorp/setup-terraform@v4
# backend.tf と backend.hcl を生成する(内容は前掲と同じ)
- name: Terraform plan
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
terraform init -backend-config=backend.hcl
terraform plan
- name: Terraform apply
if: inputs.apply
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform apply -auto-approve
トークンを GITHUB_TOKEN に渡せば、Terraform の provider がそのまま読みます。
provider の app_auth でも同じことができますが、ローカルでも同じ形で動かしたいので環境変数にしました。
App のトークンは、インストールした人の権限を引き継ぎません。 ただ App 自体が組織のメンバーとチームを変更できるので、秘密鍵を持つ人はその操作ができてしまいます。 鍵は権限管理用リポジトリの secrets にだけ置きます。
このリポジトリに write できる人は、組織の権限を書き換えられる人でもあります。 他のコードとは同居させず、専用のリポジトリにしています。
まとめ
state の置き場には Cloudflare R2 が使えます。
use_lockfile があるので、DynamoDB なしでロックまで取れます。
親子チームでは、親の権限が子に継承されて plan に出てきません。 権限は子チームにだけ書くと、コードと実際の権限が一致します。
GitHub の権限管理を Terraform に寄せるときの参考にしてみてください。