はじめに
app repo と infra repo が分かれてる構成でCDを組む際、
「GitHub Actions or CodePipeline」とツールの選択を論じてる現場、よく見ますよね。
私は勿論「どっちが好み」かはありますし、みなさんも好みはあると思うんですが。
どっちでもいいけど、CDの設計は本質的に「権限の設計」 だと思っています。
CDの設計を決めている3つの軸(要素)
主観ですが、CDを設計するにあたっての論じるべき軸(要素)は以下の3つです。
-
実行主体
GitHub Actionsのランナー or クラウド側のパイプライン
(正直どっちでもそれなりにインスタンスを選定できるので大差ない) -
デプロイ定義の置き場
app repoのワークフロー or infra repoが持つパイプライン定義
(Github API でログ収集 or CloudWatch Log になるかが大きな違い) -
デプロイ対象
アプリ成果物の差し替えのみ orcdk deployでインフラ変更まで含むか
(システム要件に大きく関わってくるし、リソース間の癒着度にも寄りますね。)
「どの組み合わせが正解か」という話は本記事ではしません。
今回は上記の3軸を固めた後の、「安全に実行するための権限設計」についてお話したいと思います。
今回は「app repoのGitHub Actionsが、アプリ成果物のみをデプロイする」構成を題材とさせていただきます。
余談
私の開発フローの好みの話なんですけど、
CIのログをgh run watchで追う開発フローに慣れちゃってるので、CDも同じ開発体験でやりきりたいんですよね...笑。
ただこの構成、選定した場合は避けられない課題があって。
GitHub Actionsで起動したランナーに対して、何らかの方法でAWSリソースを操作する権限を振る必要があります。
冒頭の主張はここに繋がるんですが、
CDの安全性は、対象ロールに「誰が・何を」できるようにするかを決めた時点でほぼ決まります。
本記事では、AWS推奨の「最小権限」として CDK for Go で実装し、ユニットテストで守るところまでをお話しします。
まず、長期アクセスキーやめましょ
おなじみの方法ですが。
ランナーに権限を渡す一番素朴な方法は、IAMユーザーを作ってアクセスキーをGitHub Repository Secretsに登録することです。
ただ、これには2つの問題があります。
-
漏洩リスク
長期クレデンシャルはSecretsから流出したら失効させるまで有効です。 -
ローテーション運用
定期的なキーの再発行・差し替えが必要です。
想像つくと思いますが、「やると宣言しても」大抵形骸化しちゃいます。
「え、じゃあ Github Actions でセキュリティ要件満たすのきびくね?」
と思うんですが、解決策あります。
GitHub OIDC federationです。
仕組みの説明ちょっと長くなりますので詳細が気になる方は以下参照下さい。
(Github公式のやつわかりやすかったです)
簡単に言うと、
「GitHub Actionsが発行する短命のIDトークンをAWS STSが検証し、一時クレデンシャルを払い出す仕組み」です。
つまり、Secretsに秘密情報を一切置かずに済みます。
では、まずはこのOIDCの土台作りから紹介しますね。
OIDC Providerの作成
AWSアカウントにGitHubをIDプロバイダーとして登録します。
CDKでは1コンストラクタで済みます。
※ CDK for Go です。
provider := awsiam.NewOpenIdConnectProvider(stack, jsii.String("GitHubOidcProvider"), &awsiam.OpenIdConnectProviderProps{
Url: jsii.String("https://token.actions.githubusercontent.com"),
ClientIds: &[]*string{
jsii.String("sts.amazonaws.com"),
},
})
注意点として、OIDC ProviderはURLごとにアカウントで1つしか作れません。すでに別のスタックや手作業で作成済みのアカウントでは、新規作成ではなく参照を使います。
provider := awsiam.OpenIdConnectProvider_FromOpenIdConnectProviderArn(
stack, jsii.String("GitHubOidcProvider"),
jsii.String("arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"),
)
新しいアカウントで完結する構成なら前者、既存アカウントに相乗りするなら後者、と使い分けて下さい。
「誰が」実行できるかを定義
OIDCで一番重要なのはTrust Policy(信頼ポリシー)です。
ここが緩いと「他人のリポジトリからでもロールをassumeできる」という事故につながります。
業務では勿論、個人開発でも厳し目に設定しておくべきです。
principal := awsiam.NewOpenIdConnectPrincipal(provider, &map[string]interface{}{
"StringEquals": map[string]string{
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
"StringLike": map[string]string{
"token.actions.githubusercontent.com:sub": cfg.GitHubOIDCSubjectPattern(),
},
})
role := awsiam.NewRole(stack, jsii.String("AppDeployRole"), &awsiam.RoleProps{
RoleName: jsii.String("role-name"),
Description: jsii.String("Role assumed by GitHub Actions OIDC to deploy app artifacts."),
AssumedBy: principal,
})
2つの条件の役割は明確に異なります。
-
aud(audience)をStringEqualsで固定
このトークンがAWS STS向けに発行されたものであることの検証。
ここは常にsts.amazonaws.comの完全一致でよいです。 -
sub(subject)をStringLikeでパターンマッチ
「どのリポジトリの、どのref(ブランチ/タグ)からのワークフローか」の検証。
ここが実質的なアクセス制御の本体です。
subのパターンは設定から組み立てています。
func (c *AppConfig) GitHubOIDCSubjectPattern() string {
// GitHubRepo("owner/repo")とGitHubRefPatternはcdk contextや環境変数から注入する想定
return fmt.Sprintf("repo:%s:ref:%s", c.Deploy.GitHubRepo, c.Deploy.GitHubRefPattern)
}
これで repo:owner/repo:ref:refs/heads/* のような文字列になります。
このrefs/heads/*というrefパターン、実は上記だとかなり緩い設定なのですが、その話は後ほどご説明します。
「ユーザー」ではなく「ロール」に権限を付与
※ 今回は、Lambda・S3・CloudFrontを操作するようなデプロイを想定しています。
ロールに付与する権限は4つのステートメント(3リソース・4ステートメント)だけです。
それぞれリソースARNを特定のリソース1つに絞っているのがポイントです。
※ 普段から権限設定しているのであれば、この辺は説明不要かもしれませんが一応細かく説明しますね。
1. Lambdaリソース操作権限を付与
role.AddToPolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("lambda:UpdateFunctionCode"),
},
Resources: &[]*string{
props.Api.Function.FunctionArn(),
},
}))
lambda:UpdateFunctionCodeのみ、対象はAPI用Lambda関数1つのみ。
関数の新規作成も設定変更も削除も出来ないです。
仮にこのロールのクレデンシャルが漏れたとしても、「出来ること」は「この1関数のコードを差し替える」だけです。
※ 今回はサンプルでLambda:UpdateFunctionを例に出してます。本番運用ではcdk deployLambdaのロールバック戦略を考える必要があります。
2. S3:バケット操作とオブジェクト操作の権限を付与
role.AddToPolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("s3:ListBucket"),
jsii.String("s3:GetBucketLocation"),
},
Resources: &[]*string{
props.Web.Bucket.BucketArn(),
},
}))
role.AddToPolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("s3:PutObject"),
jsii.String("s3:DeleteObject"),
},
Resources: &[]*string{
props.Web.Bucket.ArnForObjects(jsii.String("*")),
},
}))
S3リソースに対するアクションは、
バケット自体に対するもの(ListBucketなど、リソースはarn:aws:s3:::bucket)と
オブジェクトに対するもの(PutObjectなど、リソースはarn:aws:s3:::bucket/*)で
ARNの形が違います。
ここを雑にs3:* + Resources: ["*"]にしてしまう最悪な例をよく見ますが、分けて書けばaws s3 syncに必要な権限だけをちょうど表現できます。
CDKならBucketArn()とArnForObjects("*")で書き分けられます。
なおGetObjectすら不要です。
aws s3 syncは差分判定にListの結果(ETag・サイズ)を使うため、配信目的のデプロイならこれで足ります。
3. CloudFrontリソースの操作権限を付与
role.AddToPolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: &[]*string{
jsii.String("cloudfront:CreateInvalidation"),
},
Resources: &[]*string{
stack.FormatArn(&awscdk.ArnComponents{
Service: jsii.String("cloudfront"),
Region: jsii.String(""), // CloudFrontはグローバルリソースのため空文字
Resource: jsii.String("distribution"),
ResourceName: props.Web.Distribution.DistributionId(),
ArnFormat: awscdk.ArnFormat_SLASH_RESOURCE_NAME,
}),
},
}))
ここでCDKの小ネタを1つ。
CloudFrontのL2 construct(Distribution)には、ARNを直接返すプロパティがないんですよね。
そのためARNを自分で組み立てる必要があります。
ここで言語標準の fmt.Sprintf や CloudFormationの組み込み関数 Fn::Join を使って文字列連結をしてしまうのはアンチパターンです。
CDKには、ARNを安全かつ正確に生成するための組み込みメソッド stack.FormatArn() が用意されてて、これ結構便利です。
これを使うメリットは以下の通りです。
- アカウントIDやパーティション(aws)を自動で解決してくれる
- 「リージョンを含まないARN」も Region: "" とするだけで正確にフォーマットできる
-
ArnFormat_SLASH_RESOURCE_NAMEを指定することで、distribution/IDのようなリソース名との結合ミスを防げる
「何ができるか」より「どのリソースに対してか」を意識しましょう
Actionを絞るだけでなくResourceのARNを常に特定リソースに固定していることが大事です。
lambda:UpdateFunctionCodeを許可していても、対象が1関数なら影響範囲はその関数だけ。
逆にActionを絞ってもResourceが*なら、アカウント内の全Lambdaを書き換えられてしまいますよね。
最小権限を考えるときの軸って「動詞」ではなく「目的語」を意識すべきだと思います。
app repo が知る必要のある情報
上記の、ポリシー設定でinfra repo の作業は完了してます。
あとは、Github Actions の設定を書くだけです。
app repo側が知る必要があるのは以下の4つ。
※ 今回は Lambda・S3・CloudFront を例にしてます。
| 項目 | 例 | 用途 |
|---|---|---|
| Role ARN | arn:aws:iam::xxxx:role/role-name |
role-to-assumeに指定 |
| Lambda関数名 | sample-app-api |
update-function-codeの対象 |
| S3バケット名 | (Webスタックの出力) |
s3 syncの宛先 |
| Distribution ID | (Webスタックの出力) | invalidationの対象 |
ワークフロー設定
app repo側のワークフローは以下のみ。
アクセスキー系のsecret関連の操作は一切出てこないです。
name: Deploy
# トリガーは適宜記載願う。
on:
push:
branches: [main]
permissions:
id-token: write # OIDCトークンの発行に必須
contents: read
env:
AWS_REGION: ap-northeast-1
# 以下3つは「app repo が知る必要のある情報」の表の値
FUNCTION: sample-app-api
WEB_BUCKET: sample-app-web
DISTRIBUTION: EXXXXXXXXXXXXX
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5 # SHA 使いましょ
# ... build stepsも適宜記載願う。 ...
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/role-name
aws-region: ${{ env.AWS_REGION }}
- name: Deploy API
run: |
aws lambda update-function-code \ # cdk deploy でのLambdaのロールバック運用は考慮しません(あくまでサンプル)
--function-name "${FUNCTION}" \
--zip-file fileb://build/api.zip
- name: Deploy Web
run: |
aws s3 sync ./dist "s3://${WEB_BUCKET}" --delete # --delete オプションあんまよくないけどサンプルコードなので許して下さい
aws cloudfront create-invalidation \
--distribution-id "${DISTRIBUTION}" \
--paths "/*"
ハマりどころは2つほど?ですかね。
1つ目はpermissions: id-token: writeです。
この設定がないとconfigure-aws-credentialsが認証エラーで失敗しちゃいます。
デフォルトのGITHUB_TOKEN権限にはid-token発行が含まれないため明示が必要。
2つ目はsub条件とトリガーのミスマッチです。
Trust Policyのsub条件をrefs/heads/mainに絞っている場合ですと、on: pull_requestのワークフローからassumeしようとするとエラーとなります。
エラーが出た場合はワークフローのトリガーとsub patternを疑ってください。
ところでなんですが。このロール設定だと、全ブランチからassumeできます。
ここまでで、Secretsゼロのデプロイが回るようになりました。
「お疲れ様でした!」と言いたいところですが、Trust Policyの章でちらっと言った通り、この設定にはまだ穴があります。
githubRefPatternのデフォルト設定はrefs/heads/*なんですよね。
つまり、対象リポジトリの全ブランチから、このデプロイロールをassumeできちゃう感じです。
もっというと、feature branchにpushしただけで本番のLambdaを差し替えるワークフローが書けてしまう、ということです。
個人開発の初期段階では開発効率を優先してこれでよくても、本番運用では絞るべきです。
repo:owner/repo:ref:refs/heads/main # mainブランチのみ
repo:owner/repo:ref:refs/tags/v* # バージョンタグのみ
repo:owner/repo:environment:production # GitHub Environmentsを使う場合
特にpull requestトリガーのワークフローはsubの形式自体が異なる(repo:owner/repo:pull_request)ため、refs/heads/*ではマッチしません。「PRのCIでは読み取り専用、mainへのpushでのみデプロイ」という設計が自然に作れます。
単体テスト
CDKの利点は、慣れた言語でポリシーもテストできること。
単体テストで仕様を明示しましょう。
template := assertions.Template_FromStack(stack.Stack, nil)
// Trust Policy: 意図したrepo/refからしかassumeできないこと
template.HasResourceProperties(jsii.String("AWS::IAM::Role"), map[string]any{
"RoleName": "role-name",
"AssumeRolePolicyDocument": assertions.Match_ObjectLike(&map[string]any{
"Statement": assertions.Match_ArrayWith(&[]any{
assertions.Match_ObjectLike(&map[string]any{
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": map[string]any{
"StringEquals": map[string]any{
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
"StringLike": map[string]any{
"token.actions.githubusercontent.com:sub": "repo:owner/repo:ref:refs/heads/*",
},
},
}),
}),
}),
})
// Permissions Policy: 許可アクションが想定の4種のみであること
template.HasResourceProperties(jsii.String("AWS::IAM::Policy"), map[string]any{
"PolicyDocument": assertions.Match_ObjectLike(&map[string]any{
"Statement": assertions.Match_ArrayWith(&[]any{
assertions.Match_ObjectLike(&map[string]any{
"Action": "lambda:UpdateFunctionCode",
}),
assertions.Match_ObjectLike(&map[string]any{
"Action": []any{"s3:PutObject", "s3:DeleteObject"},
}),
assertions.Match_ObjectLike(&map[string]any{
"Action": "cloudfront:CreateInvalidation",
}),
}),
}),
})
「単体テストの価値とは?」みたいな話にはなりそうですが、
今動くことの確認よりも 「プロジェクトが拡大した際にレビューで拾えること」 が大事かなと思ってます。
わかりやすい例だと。
誰かが「CDでDynamoDBで排他ロック処理入れたいからぁ」とポリシーに1行足すPRを出したとき、このテストが落ちるか、テスト側にも変更が必要になりますよね。
権限の変更が必ずdiffとして可視化されるので、ものすごくCDKの恩恵が活きる場面だよなぁと感じます。
IAMの変更は、アプリコードの変更より事故が起きた時の影響が大きいです。
まとめ
開発事例紹介っぽい感じの記事になりましたが、ちょっとでも刺激をお届けできたのであれば幸いです。
CDKの設計とかってあまり記事に多く出回らないのでみなさんの知見とかも聞いてみたいなとも思います。
ここまで読んでいただきありがとうございます。