GitHub のマシンユーザーを駆逐して GitHub Apps 及び deploy key に移行しました
本記事は 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 として参加させる
- GitHub Apps に移行し Installation Access Token を利用する
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_TOKEN
と GITHUB_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 に現状対応していないことが分かりました、、
現在 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 で用意されている checkout はhttps://github.com
を git+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 を使った認証に対応していないようです。
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 では一緒に働く仲間を募集中です!詳細は採用ページをご覧ください。
Discussion