本文へスキップ

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 の画面表記と違います。

TerraformGitHub の画面
pullRead
triageTriage
pushWrite
maintainMaintain
adminAdmin
# チームごとに、アクセスを許可するリポジトリと権限を定義する。
#
# 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 が結びつけるもの

Terraform がコード、state、GitHub を結びつける関係と、state の置き場の条件

R2 でロックを取る

2 つの apply が .tflock を条件付き書き込みで取り合い、後発が 412 で弾かれる図

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 Actions が App のトークンで GitHub を操作し、R2 の API トークンで state を読み書きする図

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 に寄せるときの参考にしてみてください。

この記事をシェア