MIXI DEVELOPERS
🙌

GitHub のマシンユーザーを駆逐して GitHub Apps 及び deploy key に移行しました

2024/12/14に公開

本記事は MIXI DEVELOPERS Advent Calendar 2024 のシリーズ1 14日目の記事です。
サロンスタッフ予約サービス minimo のソフトウェアエンジニアをしている yhamano0312 です。

minimo に異動してくる前は事業部横断でエンジニアリングを支援する部署に所属していたのですが、その時に支援していたソーシャルベッティングサービス TIPSTAR で行なった GitHub マシンユーザーからの GitHub Apps 及び deploy key への移行について紹介します。

GitHub Enterprise Cloud 導入

MIXI では 2023 年より全社的に GitHub Enterprise Cloud (以下、GHEC)の導入を進めています。GHEC は SAML SSO が可能で MIXI では Okta と連携しています。社内ルールとして基本的に Okta アカウントは人間に対してのみ発行することになっていますが、ここで問題となるのが GitHub のマシンユーザーです。

各種 CI/CD や運用系のツールで GitHub を操作する場合にマシンユーザーを用意し必要な権限を与えて、ssh key もしくは personal access token (以下、PAT) を発行して利用するケースは多いと思います。GitHub としてもマシンユーザーを用意することは許容しています[1]。例に漏れず TIPSTAR でもマシンユーザーを利用していました。しかし、社内ルールでマシンユーザーに対して Okta アカウントの発行はできないため、GHEC 内ではマシンユーザーをそのまま利用できません。

そのため、以下の代替案がアナウンスされました。

Outside Collaborator として参加させるパターンであれば、マシンユーザーの ssh key や PAT を利用している箇所を変更せずにそのままリポジトリを GHEC に移行可能なため少ない工数で対応できます。ただし、セキュリティ的な観点で考えると有効期限が短い token を都度発行して利用する Installation Access Token を使った方が良いと思われます。

TIPSTAR では GHEC 移行に対して比較的工数を割けそうだったこともあり、マシンユーザーからの GitHub Apps 及び deploy key への移行を行いました。

前提

  • transfer 対象のリポジトリ数:70+
  • マシンユーザーで発行していた ssh key 数:20+
  • マシンユーザーで発行していた PAT 数:20+
  • 既存の Organization から GHEC 上に新規に作成した Organization にリポジトリ単位で transfer する
    • Organization 自体を GHEC に transfer 可能です。しかし、既存の Organization には TIPSTAR 以外にも様々なプロダクトのリポジトリが存在していたため、リポジトリ単位での transfer を選択しました。
  • マシンユーザーに紐づいた ssh key の移行先としてリポジトリに紐づく deploy key を許容する
    • deploy key は単一のリポジトリに紐づく ssh key です。ユーザーに紐づく ssh key と同様に有効期限が無い key になるのですが、影響範囲が単一のリポジトリに限定されるため代替手段として許容しました。

対応方針

極力リポジトリの transfer 前にやれることはやっておいて、transfer 後に必要な作業を少なくする方針にしました。
例としてはマシンユーザーに紐づいた ssh key から deploy key への移行は GHEC への transfer 前に完了させました。また、PAT から GitHub Apps Installation Access Token への移行に関しても transfer 前に完了させて、transfer 後に secret の中身を切り替えるだけにしました。

移行対応

GitHub と連携する 各 CI/CD サービスや運用系ツールで行った具体的な移行対応や躓いた点を以下に記載します。

GitHub Actions

actions の secret に 設定した PAT を利用していた箇所を GitHub Apps Installation Access Token を使うように修正しました。token という機微な情報を扱うため 3rd-party ではなく GitHub 公式の create-github-app-token actions を用いて token を生成するようにしました。注意点としてはデフォルトだと actions を実行したリポジトリに対する権限しか有していないため、他リポジトリの権限も欲しい場合は owner と repositories フィールドを指定する必要があります。

on: [workflow_dispatch]

jobs:
  hello-world:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ vars.APP_ID }}
          private-key: ${{ secrets.PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}
          repositories: |
            repo1
            repo2
      - uses: peter-evans/create-or-update-comment@v3
        with:
          token: ${{ steps.app-token.outputs.token }}
          issue-number: ${{ github.event.issue.number }}
          body: "Hello, World!"

なお、今まではリポジトリ毎に secret を登録していたのですが今回のタイミングで複数リポジトリで参照するような secret は Organization レベルに登録したことで、同じ secret を複数箇所に登録する必要がなくなりました。

CircleCI

GitHub PAT 移行

Project に紐づく Environment に設定されていた PAT を利用していた箇所を GitHub Apps Installation Access Token を使うように修正しました。CircleCI には token を生成してくれる orb が無さそうだったので、自作しました。既に社内の別部署で token を生成するスクリプトを共有してくれていたので、それをベースに shell で実装しました。token 生成処理を command として定義して、job の step として呼び出すようにしました。

commands:
  generate-github-app-token:
    description: "generate github app token"
    parameters:
      github-app-id:
        type: env_var_name
        default: GITHUB_APP_ID
      encoded-github-app-pem:
        type: env_var_name
        default: ENCODED_GITHUB_APP_PEM
    steps:
      - run: |
          base64url() {
            openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
          }
          sign() {
            github_app_pem=$(echo ${<< parameters.encoded-github-app-pem >>} | base64 -d)
            openssl dgst -binary -sha256 -sign <(printf '%s' "${github_app_pem}")
          }

          header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"
          now="$(date '+%s')"
          iat="$((now - 60))"
          exp="$((now + (3 * 60)))"
          template='{"iss":"%s","iat":%s,"exp":%s}'
          payload="$(printf "${template}" "${<< parameters.github-app-id >>}" "${iat}" "${exp}" | base64url)"
          signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"
          jwt="${header}.${payload}.${signature}"
          
          github_repository=$(echo ${CIRCLE_REPOSITORY_URL} | sed -e 's/^git@github.com:\(.*\)\.git$/\1/')
          installation_id="$(curl --location --silent --request GET \
            --url "https://api.github.com/repos/${github_repository}/installation" \
            --header "Accept: application/vnd.github+json" \
            --header "Authorization: Bearer ${jwt}" \
            | jq -r '.id'
          )"

          token="$(curl --location --silent --request POST \
            --url "https://api.github.com/app/installations/${installation_id}/access_tokens" \
            --header "Accept: application/vnd.github+json" \
            --header "Authorization: Bearer ${jwt}" \
            | jq -r '.token'
          )"
          
          echo "export GITHUB_APP_INSTALLATION_ACCESS_TOKEN=${token}" >> $BASH_ENV

GitHub PAT 移行での躓き

job の処理で gh command を利用している箇所がありました。生成した GitHub Apps Installation Access Token を GITHUB_TOKEN 環境変数で渡していたのですが、リポジトリ移行後に認証エラーとなりました。確認してみると、CircleCI の environment にマシンユーザーの PAT が GH_TOKEN 環境変数名で残ったままでした。gh command は環境変数として GH_TOKENGITHUB_TOKEN 両方に対応しているのですが、GH_TOKEN の方が優先されます

GH_TOKEN, GITHUB_TOKEN (in order of precedence)

大人しく GH_TOKENを environment から削除しました。CircleCI に限った話ではないので、gh command を利用している場合は気をつけましょう。個人的にも過去に2,3回これでハマったことあるのに最初は全然気づきませんでした、、

CircleCI パーソナルトークン 移行(はできなかったので保留)

job の処理で CircleCI の API を叩く処理がありました。CircleCI の API を叩く際の認証情報にはマシンユーザーの CircleCI personal token を利用していました。そのため、代わりに project token を使おうとしたのですが、Circle CI の v2 API は project token に現状対応していないことが分かりました、、
https://circleci.com/docs/ja/api-intro/

現在 API v2 でサポートされているのは パーソナル API トークン のみです。 プロジェクトトークン は、現在 API v2 ではサポートされていません。

GitHub Apps のような仕組みも無いため、仕方なくマシンユーザーの personal token を利用し続けることにしました。v2 API が project token に対応されたら移行予定です。

ssh key 移行

移行前は Project 設定の Checkout SSH Keys にマシンユーザーの ssh key を設定して、 job 内でリポジトリの checkout や git 操作をしていました。そのためマシンユーザーの ssh key の代わりに、リポジトリに紐づく deploy key に変更しました。しかし、ここで問題となるのが deploy key は単一のリポジトリにしか紐づかないので、submodule の pull 等で他のリポジトリに対する処理を行いたい場合に deploy key が利用できないことです。そのため、複数リポジトリを触る処理の場合には GitHub Apps Installation Access Token を使うように git config を設定しました。ただし、CircleCI で用意されている checkouthttps://github.comgit+ssh:// に変更したりと鍵優先にする設定が入っているため、custom checkout step を自作して使うようにしました。

commands:
  git_config:
    description: Setup git config
    parameters:
      user_name:
        description: git user name
        type: string
        default: app[bot]
      user_email:
        description: git user email
        type: string
        default: app[bot]@users.noreply.github.com
      github-app-id:
        type: env_var_name
        default: GITHUB_APP_ID
      encoded-github-app-pem:
        type: env_var_name
        default: ENCODED_GITHUB_APP_PEM
    steps:
      - generate-github-app-token:
          github-app-id: << parameters.github-app-id >>
          encoded-github-app-pem: << parameters.encoded-github-app-pem >>
      - run:
          name: Setup git config
          command: |
            git config --global user.name << parameters.user_name >>
            git config --global user.email << parameters.user_email >>
            mkdir -p $HOME/.config
            echo "https://x-access-token:${GITHUB_APP_INSTALLATION_ACCESS_TOKEN}@github.com" > $HOME/.config/git-credential
            git config --global credential.helper "store --file=$HOME/.config/git-credential"
            git config --global url."https://github.com/".insteadOf 'git@github.com:'

  checkout-use-token:
    description: Checkout use token, require call git_config
    steps:
      - run:
          name: Replace CIRCLE_WORKING_DIRECTORY
          command: |
            # $CIRCLE_WORKING_DIRECTORY が `~` を含んでしまい、mkdir等で `~` という名前のディレクトリを作成してしまうため
            # https://discuss.circleci.com/t/circle-working-directory-doesnt-expand/17007/5
            echo 'CIRCLE_WORKING_DIRECTORY="${CIRCLE_WORKING_DIRECTORY/#\~/$HOME}"' >> $BASH_ENV
      - run:
          name: Checkout use token
          command: |
            set -e
            # Workaround old docker images with incorrect $HOME
            # check https://github.com/docker/docker/issues/2968 for details
            if [ "${HOME}" = "/" ]
            then
              export HOME=$(getent passwd $(id -un) | cut -d: -f6)
            fi
            if [ -e "$CIRCLE_WORKING_DIRECTORY/.git" ] ; then
              echo 'Fetching into existing repository'
              existing_repo='true'
              cd "$CIRCLE_WORKING_DIRECTORY"
              git remote set-url origin "$CIRCLE_REPOSITORY_URL" || true
            else
              echo 'Cloning git repository'
              existing_repo='false'
              mkdir -p "$CIRCLE_WORKING_DIRECTORY"
              cd "$CIRCLE_WORKING_DIRECTORY"
              git clone --no-checkout "$CIRCLE_REPOSITORY_URL" .
            fi
            if [ "$existing_repo" = 'true' ] || [ 'false' = 'true' ]; then
              echo 'Fetching from remote repository'
              if [ -n "$CIRCLE_TAG" ]; then
                git fetch --force --tags origin
              else
                git fetch --force origin +refs/heads/${CIRCLE_BRANCH}:refs/remotes/origin/${CIRCLE_BRANCH}
              fi
            fi
            if [ -n "$CIRCLE_TAG" ]; then
              echo 'Checking out tag'
              git checkout --force "$CIRCLE_TAG"
              git reset --hard "$CIRCLE_SHA1"
            else
              echo 'Checking out branch'
              git checkout --force -B "$CIRCLE_BRANCH" "$CIRCLE_SHA1"
              git --no-pager log --no-color -n 1 --format='HEAD is now at %h %s'
            fi

jobs:
  test:
    docker:
      - image: cimg/base:stable
    steps:
      - git_config
      - checkout-use-token
      - run:
          name: Pull Submodules
          command: |
            git submodule sync
            git submodule update --init

Bitrise

GitHub PAT 移行

workflow に紐づく secret に設定されていた PAT を利用していた箇所を GitHub Apps Installation Access Token を使うように修正しました。CircleCI と同様に token を生成するスクリプトを用意しました。

    - script@1:
        title: Create github token
        inputs:
        - content: |
            #!/usr/bin/env bash
            # fail if any commands fails
            set -e
            # debug log
            set -x

            base64url() {
                openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
            }
            sign() {
                openssl dgst -binary -sha256 -sign <(printf '%s' "${GITHUB_APP_PEM}")
            }

            header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"
            now="$(date '+%s')"
            iat="$((now - 60))"
            exp="$((now + (10 * 60)))"
            template='{"iss":"%s","iat":%s,"exp":%s}'
            payload="$(printf "${template}" "${GITHUB_APP_ID}" "${iat}" "${exp}" | base64url)"
            signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"
            jwt="${header}.${payload}.${signature}"

            github_repository=$(echo ${GIT_REPOSITORY_URL} | sed -e 's/^git@github.com:\(.*\)\.git$/\1/')
            installation_id="$(curl --location --silent --request GET \
                --url "https://api.github.com/repos/${github_repository}/installation" \
                --header "Accept: application/vnd.github+json" \
                --header "Authorization: Bearer ${jwt}" \
                | jq -r '.id'
            )"

            GITHUB_APP_INSTALLATION_ACCESS_TOKEN="$(curl --location --silent --request POST \
                --url "https://api.github.com/app/installations/${installation_id}/access_tokens" \
                --header "Accept: application/vnd.github+json" \
                --header "Authorization: Bearer ${jwt}" \
                | jq -r '.token'
            )"

            envman add --key GITHUB_TOKEN --value "${GITHUB_APP_INSTALLATION_ACCESS_TOKEN}" --sensitive

ssh key 移行

移行前は App の Integrations 設定の Repository authorization credentials に マシンユーザーの ssh key を設定して、 workflow 内でリポジトリの clone や git 操作をしていました。そのためマシンユーザーの ssh key の代わりに、リポジトリに紐づく deploy key に変更しました。複数リポジトリの操作をするような処理の場合 deploy key を利用できないため、GitHub Installation Access Token を使うように試みたのですが、SwiftPM で指定された private repository を fetch する箇所が https による認証は通ってるのに途中で処理が止まってログも吐かずに workflow がタイムアウトするという謎現象が起こりました、、
仕方なく fetch する全ての private repository に deploy key を設定して、git config にエイリアスを設定して fetch できるようにしました。

workflows:
  test:
    steps:
    - activate-ssh-key@4:
        run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}'
    - git-clone@8: {}
    - script@1:
        title: Setup SSH
        inputs:
        - content: |-
            #!/usr/bin/env bash
            # fail if any commands fails
            set -e
            # debug log
            set -x

            # Setup ssh
            rm ~/.ssh/config

            ## set hoge repository
            echo "${HOGE_DEPLOY_KEY}" > ~/.ssh/hoge
            chmod 600 ~/.ssh/hoge
            echo -e "Host hoge\n\tHostName github.com\n\tIdentityFile ~/.ssh/hoge" >> ~/.ssh/config
            git config --global url."git@hoge:mixi-test-org/hoge.git".insteadOf 'git@github.com:mixi-test-org/hoge.git'

            ## set fuge repository
            echo "${FUGE_DEPLOY_KEY}" > ~/.ssh/fuge
            chmod 600 ~/.ssh/fuge
            echo -e "Host fuge\n\tHostName github.com\n\tIdentityFile ~/.ssh/fuge" >> ~/.ssh/config
            git config --global url."git@fuge:mixi-test-org/fuge.git".insteadOf 'git@github.com:mixi-test-org/fuge.git'
                             ・
                             ・
                             ・
                             ・
                             ・
                             ・
            ## set public repository
            ## public でも何かしらの key を設定する必要があるため
            echo -e "Host *\n\tIdentityFile ~/.ssh/hoge" >> ~/.ssh/config

            ssh-keyscan github.com >> ~/.ssh/known_hosts

    - script@1:
        title: test run
        inputs:
        - content: |-
            #!/usr/bin/env bash
            # fail if any commands fails
            set -e
            # debug log
            set -x

            # 前段の Setup SSH step で行った git config 設定が利用されるように
            # activate-ssh-key step で有効となった ssh-agent を無視する。 
            unset SSH_AGENT_PID
            unset SSH_AUTH_SOCK

            fastlane ~~

git-sync

GKE 上に立てた運用系の pod で特定リポジトリの中身を sync するのに git-sync のコンテナを立てて利用しています。sync 時に利用していた ssh key にマシンユーザーの ssh key を利用していたため、deploy key に切り替えました(key 自体は Google Cloud の Secret Manager に保存して、external-secrets を経由して pod にマウントしています。)。
sync しているリポジトリには submodule が設定されていたのですが、運用時に submodule で設定されたリポジトリのコードは利用していなかったので、git-sync の起動オプションで -submodules=offを設定して1つの deploy key のみで同期できるようにしました。

なお、2024/12 時点では git-sync は GitHub Apps を使った認証に対応していないようです。
https://github.com/kubernetes/git-sync/issues/769

ArgoCD

ArgoCD で k8s のマニフェストを管理しているリポジトリとの sync にマシンユーザーの ssh key を利用していたため、GitHub Apps を利用するように変更しました。ArgoCD は GitHub Apps による認証に対応しているため、GitHub Apps の id や key を secret に登録するだけです。

こちらも secret は external-secrets 経由で登録しています。

extraObjects:
  - apiVersion: external-secrets.io/v1beta1
    kind: ExternalSecret
    metadata:
      name: k8s-manifest-repo
      namespace: argocd
    spec:
      secretStoreRef:
        kind: SecretStore
        name: argocd-secret
      refreshInterval: 3m
      target:
        name: k8s-manifest-repo
        template:
          engineVersion: v2
          metadata:
            labels:
              argocd.argoproj.io/secret-type: repository
          data:
            type: git
            url: https://github.com/mixi-test-org/k8s-manifest.git
            githubAppID: '{{ "{{ .githubAppID | toString }}" }}'
            githubAppInstallationID: '{{ "{{ .githubAppInstallationID | toString }}" }}'
            githubAppPrivateKey: '{{ "{{ .githubAppPrivateKey | toString }}" }}'
      data:
      - secretKey: githubAppID
        remoteRef:
          key: github-app-id-dev-app
          version: "1"
      - secretKey: githubAppInstallationID
        remoteRef:
          key: github-app-installation-id-dev-app
          version: "1"
      - secretKey: githubAppPrivateKey
        remoteRef:
          key: github-app-private-key-dev-app
          version: "1"

Go ツール

TIPSTAR では様々な運用系のツールを Go で実装しており、GKE や Cloud Run で稼働させています。それらツールの中には GitHub への git 操作や API 実行をしているものもあります。

API 実行のみであれば、ghinstallationを利用して GitHub Apps Installation Access Token を利用した client を生成すれば解決です。有効期限は1時間なのですが、ghinstallation を使えば token の refresh まで見てくれるため、運用ツールを API server として稼働させても問題ありません。

import "github.com/bradleyfalzon/ghinstallation/v2"

func main() {
    // Shared transport to reuse TCP connections.
    tr := http.DefaultTransport

    // Wrap the shared transport for use with the app ID 1 authenticating with installation ID 99.
    itr, err := ghinstallation.NewKeyFromFile(tr, 1, 99, "2016-10-19.private-key.pem")
    if err != nil {
        log.Fatal(err)
    }

    // Use installation transport with github.com/google/go-github
    client := github.NewClient(&http.Client{Transport: itr})
}

go-git で git 操作する場合は GitHub Apps Installation Access Token の生成から設定、refresh を自作する必要があります。処理周りを全て記載するとごちゃっとなってしまうので token 生成部分だけ例示を載せておきます。

import (
	"context"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/google/go-github/v56/github"
	"golang.org/x/oauth2"
)
func getInstallationAccessToken(ctx context.Context) (*github.InstallationToken, error) {
	jwt, err := createJWT("/path/app-id", "/path/app-pem")
	if err != nil {
		return nil, err
	}
	ts := oauth2.StaticTokenSource(
		&oauth2.Token{AccessToken: jwt},
	)
	tc := oauth2.NewClient(ctx, ts)
	client := github.NewClient(tc)
	org, repo, err := parseOrgAndRepo("https://github.com/mixi-test-org/test-repo.git")
	if err != nil {
		return nil, err
	}
	installation, _, err := client.Apps.FindRepositoryInstallation(ctx, org, repo)
	if err != nil {
		return nil, err
	}
	token, _, err := client.Apps.CreateInstallationToken(ctx, installation.GetID(), nil)
	if err != nil {
		return nil, err
	}
	return token, nil
}

func createJWT(appIDPath string, privateKeyPath string) (string, error) {
	appID, err := os.ReadFile(appIDPath)
	if err != nil {
		return "", err
	}
	githubAppID, err := strconv.Atoi(strings.TrimSpace(string(appID)))
	if err != nil {
		return "", err
	}
	privateKey, err := os.ReadFile(privateKeyPath)
	if err != nil {
		return "", err
	}
	parsedKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
	if err != nil {
		return "", err
	}
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
		"iat": time.Now().Add(-1 * time.Minute).Unix(),
		"exp": time.Now().Add(10 * time.Minute).Unix(),
		"iss": githubAppID,
	})
	signedToken, err := token.SignedString(parsedKey)
	if err != nil {
		return "", err
	}
	return signedToken, nil
}

func parseOrgAndRepo(repositoryURL string) (string, string, error) {
	u, err := url.Parse(repositoryURL)
	if err != nil {
		return "", "", err
	}
	parts := strings.Split(u.Path, "/")
	org := parts[1]
	repo := strings.TrimSuffix(parts[2], ".git")
	return org, repo, nil
}

まとめ

TIPSTAR では GHEC 移行に伴ってマシンユーザーから GitHub Apps 及び deploy key に移行しました。いざやってみると影響範囲が広くて心が挫けそうになりましたが、チームメンバーと協力して移行を完了させることができました!
マシンユーザーからの移行を検討している方の参考になれば幸いです。

最後に

MIXI では一緒に働く仲間を募集中です!詳細は採用ページをご覧ください。
https://mixigroup-recruit.mixi.co.jp/

脚注
  1. https://docs.github.com/ja/get-started/learning-about-github/types-of-github-accounts#user-accounts ↩︎

MIXI DEVELOPERS
MIXI DEVELOPERS

Discussion