--- title: Cloud Foundry(Tanzu Application Service)上のアプリからIAMロールでAWSリソースにアクセスしたい tags: ["AWS", "Cloud Foundry", "Pivotal Cloud Foundry", "TAS", "TLS", "mTLS", "OIDC"] categories: ["Dev", "PaaS", "CloudFoundry", "PCF"] date: 2024-08-28T09:27:13Z updated: 2024-08-28T09:29:44Z --- AWSでのサービスアクセスには、IAMユーザーよりもIAMロールの利用が推奨されています。IAMユーザーは固定のアクセスキーを使うので、漏洩や管理が大変です。 対して、IAMロールは一時的なトークンを使ってアクセスを制御できるため、セキュリティが高く柔軟です。必要なときだけアクセスを許可し、不要なときは簡単に取り消せるので、より安全で便利な方法です。 Cloud Foundry(本記事ではTanzu Application Serviceを想定)のアプリからAWSのサービスにアクセスするケースを考えます。 **目次** ### 実現方法 Cloud FoundryのアプリははDiego Cellと呼ばれるVM上のコンテナで起動します。内部的にはcontainerdとruncが使用されています。 AWSでCloud Foundryを運用する場合は、Diego CellはEC2インスタンスにインストールされます。 現状、Cloud FoundryのアプリでIAMロールを使うには、このEC2インスタンスのInstance Profileを使わざるを得ないです。 しかし、当然ながら、VM単位でIAMロールを設定してしまうと、このVM上にいる全てのコンテナが同一ロールをもってしまいます。これは一般的には許容されないでしょう。 アプリ単位でIAMロールを持たせる方法として、Web Identity TokenとAWS STS(Security Token Service)を使う方法があります。 この方法では、アプリがWeb Identity Token(OIDCプロバイダーから発行されるJWTなど)を取得し、そのトークンを使ってSTSに一時的なセキュリティクレデンシャルをリクエストします。 具体的には、アプリがまずWeb Identity Tokenを取得します。次に、そのトークンをAWS STSのAssumeRoleWithWebIdentity APIに渡し、IAMロールを引き受けるリクエストを行います。 STSはこのリクエストを検証し、アプリに対して一時的なアクセス権を持つセキュリティトークンを発行します。このトークンを使って、アプリは必要なAWSリソースにアクセスできるようになります。 この方法を使うことで、アプリケーションごとに固有のIAMロールを持たせることができ、細かいアクセス制御が可能になります。また、固定のアクセスキーを使用せず、一時的なトークンを使うため、セキュリティが向上します。 この手法をCloud Foundryでどうやって実現すれば良いでしょうか? Cloud FoundryのアプリにはWeb Identity Tokenそのものはありませんが、アプリケーションインスタンスを特定するための[Instance Identity](https://docs.cloudfoundry.org/devguide/deploy-apps/instance-identity.html)という仕組みがあります。 中身はTLS証明書です。各コンテナに自身を証明する公開鍵が環境変数`CF_INSTANCE_CERT`のパス上に、秘密鍵が`CF_INSTANCE_KEY`のパス上に存在します。 `cf ssh`でコンテナにログインして以下のコマンドを叩くと証明書の中身を確認できます。Diegoの認証局から証明書が発行されます。 ```bash $ cat $CF_INSTANCE_CERT | openssl x509 -noout -text Certificate: Data: Version: 3 (0x2) Serial Number: fc:4f:ba:ee:97:01:49:2f:74:e2:f8:94:94:ba:37:a9 Signature Algorithm: sha256WithRSAEncryption Issuer: CN = Diego Instance Identity Intermediate CA Validity Not Before: Aug 28 06:38:44 2024 GMT Not After : Aug 29 06:38:44 2024 GMT Subject: OU = app:f60fab0d-2937-46f7-ad66-774d910826fd + OU = space:34e1bb23-0e76-4aad-95d7-1abe3ea1dcd8 + OU = organization:4b84793c-f3ea-4a55-92b7-942726aac163, CN = cfd85d7a-6140-4da4-7505-0d7f ... ``` Subjectにはこのアプリのapp guidとspace guidとorg guidが含まれていることがわかります。 自身を証明する情報がTLS証明書という形ですでに用意されているので、この証明書をJWTに変換できれば、AssumeRoleWithWebIdentityが使えそうです。 ということでこの変換処理を行う仕組み(CF Identity Token Service)を実装してみました。 https://github.com/making/cf-identity-token-service 仕組みはシンプルでmTLSで認証されたクライアント証明書のSubjectからapp guid、space guid、org guidを抽出して、JWTのclaimに変換するだけです。 > [!NOTE] Spring BootでmTLSを設定する方法は[こちらの記事](/entries/816)に書きましたが、Instance Identityを使ったクライアントTLS認証はアプリ側で実装しなくても、Cloud FoundryのRouter側で自動的に行われ、検証済みの証明書を`X-Forwarded-Client-Cert`ヘッダーに入れてくれます。 Cloud Foundryのjava-buildpackで自動追加される https://github.com/cloudfoundry/java-buildpack-client-certificate-mapper のServlet Filterを使うことで、このヘッダーからクライアント証明書を含むリクエストを作成してくれます。従って、CF Identity Token ServiceをCloud Foundry上にデプロイする分にはmTLSの設定は不要で、証明書から欲しいデータを抽出するだけで良いです。 CF Identity Token Serviceを使ったIAM認証は次の図のようなフローになります。 image (図は[こちらの記事](https://aws.amazon.com/jp/blogs/containers/diving-into-iam-roles-for-service-accounts/)のものを参考にしました) では実際に試してみましょう。 ### CF Identity Token Serviceのデプロイ CF Identity Token ServiceはJWTを署名するためのRSAの公開鍵と秘密鍵が必要なので、次のコマンドで生成します。 ```bash openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -outform PEM -pubout -out public.pem openssl pkcs8 -topk8 -inform PEM -in private.pem -out private_key.pem -nocrypt rm -f private.pem ``` Docker Imageを使ってCF Identity Token Serviceを`cf push`します。公開鍵と秘密鍵はここでは環境変数に設定します。 ```bash cf push cits -o ghcr.io/making/cf-identity-token-service:jvm --no-start cf set-env cits JWT_PRIVATEKEY base64:$(cat private_key.pem | base64 -w0) cf set-env cits JWT_PUBLICKEY base64:$(cat public.pem | base64 -w0) cf start cits ``` `curl`コマンドを含むアプリに`cf ssh`でログインします。 > [!NOTE] Cloud FoundryにBuildpackから作ったコンテナには`curl`が含まれています。 コンテナ内で次のコマンドを実行します。 ``` CITS_DOMAIN=cits. curl --cert $CF_INSTANCE_CERT --key $CF_INSTANCE_KEY -XPOST https://$CITS_DOMAIN/token -w '\n' ``` 次のようなJWTが返るでしょう ``` eyJraWQiOiJmaXJzdCIsInR5cCI6IkpXVCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiI0Yjg0NzkzYy1mM2VhLTRhNTUtOTJiNy05NDI3MjZhYWMxNjM6Njc1NWIxOWQtYzU0My00ZTBjLWE0YjMtY2Q2ZTdjOWM2OGEzOjAyNzU2MTkxLWQ4NjktNDgwNi05NzE3LWE2ZWVjNTE0MmU4YSIsImF1ZCI6InN0cy5hbWF6b25hd3MuY29tIiwiYXBwX2d1aWQiOiIwMjc1NjE5MS1kODY5LTQ4MDYtOTcxNy1hNmVlYzUxNDJlOGEiLCJvcmdfZ3VpZCI6IjRiODQ3OTNjLWYzZWEtNGE1NS05MmI3LTk0MjcyNmFhYzE2MyIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojg0NDMiLCJleHAiOjE3MjQ0MzAwODcsInNwYWNlX2d1aWQiOiI2NzU1YjE5ZC1jNTQzLTRlMGMtYTRiMy1jZDZlN2M5YzY4YTMiLCJpYXQiOjE3MjQzODY4ODd9.MDvgofP3-NmvJKGn7TuHvdHQJmcQEexC4NEmwMPQNss1gyfoOwcXvUne7LPfSr8OHPc0QSX9L1i6r9nHOa-E9czWGbLYyDldXC_aIoPSOupypRFG2frprBYTDmHS5fooyRjzLf_2e4j6Qlwac8UNqRVEfVyPWH2uxrIK1VStaiP7NvW-q03AL11IFYK1g_S0hW9yWkG03hpbPuwl-kpQUC6T40MD4B4oORaDMwWvM53X3v5gnNyJ2A3N3inhSy2Wkkw5i7HXLXfxJ5HTl26EE0pEKVRswD-d14fP5yGUQjrfG57cePbdX3PvKCV2BtmDRbw9vqd9wxwYM6ZAPLFPUA% ``` デコードします。次のようなペイロードを確認できます。 ```bash $ echo "$(curl -s --cert $CF_INSTANCE_CERT --key $CF_INSTANCE_KEY -XPOST https://$CITS_DOMAIN/token | cut -d '.' -f2)==" | base64 -d | jq . { "sub": "4b84793c-f3ea-4a55-92b7-942726aac163:34e1bb23-0e76-4aad-95d7-1abe3ea1dcd8:f60fab0d-2937-46f7-ad66-774d910826fd", "aud": "sts.amazonaws.com", "app_guid": "f60fab0d-2937-46f7-ad66-774d910826fd", "org_guid": "4b84793c-f3ea-4a55-92b7-942726aac163", "iss": "https://cits.apps.sandbox.aws.maki.lol", "exp": 1724876612, "space_guid": "34e1bb23-0e76-4aad-95d7-1abe3ea1dcd8", "iat": 1724833412 } ``` `sub` claimが`::`という形式になっています。 ### CF Identity Token ServiceをAWS IAMのOIDCプロバイダーとして登録する デプロイしたCF Identity Token ServiceをAWS IAMのIDプロバイダーとして登録します。 https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html#oidc-obtain-thumbprint を参考にthumbprintを取得します。 ```bash # CITS_DOMAIN=cits. CITS_DOMAIN=$(cf curl /v3/apps/$(cf app cits --guid)/routes | jq -r '.resources[0].url') FINGERPRINT=$(openssl s_client -servername $CITS_DOMAIN -showcerts -connect $CITS_DOMAIN:443 /dev/null | openssl x509 -fingerprint -sha1 -noout | sed 's/sha1 Fingerprint=//' | sed 's/://g') ``` https://docs.aws.amazon.com/IAM/latest/UserGuide/iam_example_iam_CreateOpenIdConnectProvider_section.html を参考にOIDC Providerを登録します。 ```bash cat < oidc-provider.json { "Url": "https://$CITS_DOMAIN", "ClientIDList": [ "sts.amazonaws.com" ], "ThumbprintList": [ "$FINGERPRINT" ] } EOF aws iam create-open-id-connect-provider --cli-input-json file://oidc-provider.json ``` 登録したOIDCプロバイダーのARNを取得します。 ```bash OIDC_PROVIDER_ARN=$(aws iam list-open-id-connect-providers --query "OpenIDConnectProviderList[?ends_with(Arn, '$CITS_DOMAIN')].Arn" --output text) ``` ### IAMロールの作成 現在の作業中のOrg/Space配下のアプリに対するIAM Roleを作成します。 ```bash # current org/space name ORG_NAME=$(cat ~/.cf/config.json | jq -r .OrganizationFields.Name) SPACE_NAME=$(cat ~/.cf/config.json | jq -r .SpaceFields.Name) ORG_GUID=$(cf org $ORG_NAME --guid) SPACE_GUID=$(cf space $SPACE_NAME --guid) ``` ```bash cat << EOF > cf-${ORG_NAME}-${SPACE_NAME}-trust-policy.json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "${OIDC_PROVIDER_ARN}" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringLike": { "${CITS_DOMAIN}:sub": "${ORG_GUID}:${SPACE_GUID}:*", "${CITS_DOMAIN}:aud": "sts.amazonaws.com" } } } ] } EOF aws iam create-role --role-name cf-${ORG_NAME}-${SPACE_NAME} --assume-role-policy-document file://cf-${ORG_NAME}-${SPACE_NAME}-trust-policy.json ``` ### ポリシーの作成 このIAMロールに対して、DynamoDBのテーブルに対するアクセス権を与えましょう。アクセスできるテーブルは、テーブル名に`--`のPrefixがついたものだけに限定するように次のポリシーを作成します。 ```bash export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) export AWS_REGION=ap-northeast-1 cat < cf-${ORG_NAME}-${SPACE_NAME}-policy-dynamo.json { "Version": "2012-10-17", "Statement": [ { "Sid": "PrefixFullAccess", "Effect": "Allow", "Action": "dynamodb:*", "Resource": "arn:aws:dynamodb:${AWS_REGION}:${AWS_ACCOUNT_ID}:table/${ORG_NAME}-${SPACE_NAME}-*" } ] } EOF aws iam put-role-policy --role-name cf-${ORG_NAME}-${SPACE_NAME} --policy-name dynamo-prefix-full-access-${ORG_NAME}-${SPACE_NAME} --policy-document file://cf-${ORG_NAME}-${SPACE_NAME}-policy-dynamo.json ``` ### AWS CLIでアクセス このIAMロールを使ってaws cliからDynamoDBにアクセスしてみましょう。 次のコマンドでaws-cliを含むコンテナイメージを`cf push`でデプロイし、`cf ssh`でログインします。 ```bash # CITS_DOMAIN=cits. CITS_DOMAIN=$(cf curl /v3/apps/$(cf app cits --guid)/routes | jq -r '.resources[0].url') cf push aws-cli -m 128m -o public.ecr.aws/aws-cli/aws-cli --no-route -u process --no-manifest -c 'sleep infinity' --no-start cf set-env aws-cli AWS_REGION ap-northeast-1 cf set-env aws-cli AWS_ROLE_ARN $(aws iam get-role --role-name cf-${ORG_NAME}-${SPACE_NAME} --query 'Role.Arn' --output text) cf set-env aws-cli AWS_WEB_IDENTITY_TOKEN_FILE /tmp/token cf set-env aws-cli AWS_ROLE_SESSION_NAME cf-demo cf set-env aws-cli CITS_DOMAIN $CITS_DOMAIN cf start aws-cli cf ssh aws-cli ``` ``` export PATH=$PATH:/usr/local/bin curl -s -XPOST https://${CITS_DOMAIN}/token --cert ${CF_INSTANCE_CERT} --key ${CF_INSTANCE_KEY} -o ${AWS_WEB_IDENTITY_TOKEN_FILE} aws sts get-caller-identity ``` 次のようなJSONが返るでしょう。 ```json { "UserId": "****:cf-demo", "Account": "****", "Arn": "arn:aws:sts::****:assumed-role/cf--/cf-demo" } ``` DynamoDBにアクセスします。引き続き、コンテナ内で以下のコマンドを実行します。 > [!NOTE] [この記事](/entries/815)で作成したテーブルを利用します。 ```bash TABLENAME=--movie # changeme aws dynamodb create-table \ --table-name ${TABLENAME} \ --attribute-definitions \ AttributeName=movieId,AttributeType=S \ AttributeName=title,AttributeType=S \ AttributeName=genre,AttributeType=S \ --key-schema \ AttributeName=movieId,KeyType=HASH \ --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ --global-secondary-indexes \ '[ { "IndexName": "title-index", "KeySchema": [{"AttributeName":"title","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} }, { "IndexName": "genre-index", "KeySchema": [{"AttributeName":"genre","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} } ]' | cat echo "wait until the table is created...." sleep 20 aws dynamodb put-item \ --table-name ${TABLENAME} \ --item \ '{ "movieId": {"S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5"}, "title": {"S": "Inception"}, "releaseYear": {"N": "2010"}, "genre": {"S": "Science Fiction"}, "rating": {"N": "8.8"}, "director": {"S": "Christopher Nolan"} }' aws dynamodb put-item \ --table-name ${TABLENAME} \ --item \ '{ "movieId": {"S": "2a4b6d72-789b-4a1a-9c7f-74e5a8f7676d"}, "title": {"S": "The Matrix"}, "releaseYear": {"N": "1999"}, "genre": {"S": "Action"}, "rating": {"N": "8.7"}, "director": {"S": "The Wachowskis"} }' aws dynamodb put-item \ --table-name ${TABLENAME} \ --item \ '{ "movieId": {"S": "3f6c8f74-2e6a-48e9-a07f-034f8a67b9e6"}, "title": {"S": "Interstellar"}, "releaseYear": {"N": "2014"}, "genre": {"S": "Adventure"}, "rating": {"N": "8.6"}, "director": {"S": "Christopher Nolan"} }' aws dynamodb scan --table-name ${TABLENAME} | cat ``` テーブルが作成され、データが投入されたことがわかります。 これでSTSを使ってIAMロールでAWSリソースにアクセスできました。 権限のないテーブルにアクセスしようとすると想定通り拒否されます。 ``` bash-4.2# aws dynamodb scan --table-name movie An error occurred (AccessDeniedException) when calling the Scan operation: User: arn:aws:sts::****:assumed-role/cf-****-****/cf-demo is not authorized to perform: dynamodb:Scan on resource: arn:aws:dynamodb:ap-northeast-1:****:table/movie because no identity-based policy allows the dynamodb:Scan action ``` ### サイドカーを使ってトークンを取得 先の例ではトークンの取得を手動で行いました。実際にWebアプリをデプロイする場合には、このトークンの取得及び更新はバックグランドで自動でやって欲しいです。 そこでトークン取得処理を[サイドカー](https://docs.cloudfoundry.org/devguide/sidecars.html)で実装します。 前例で作ったDynamoDBのテーブルにアクセスするアプリとして[この記事](/entries/815)で作成したSpring Bootアプリをデプロイします。 ``` git clone https://github.com/making/demo-scylladb-alternator cd demo-scylladb-alternator ``` AWS STSを使うためにはSDKの追加が必要ですので、`pom.xml`に次のdependencyを追加します。 ```xml software.amazon.awssdk sts commons-logging commons-logging ``` アプリをビルドします。 ``` ./mvnw clean package ``` 次の`manifest.yml`を作成します。 ```yaml cat <<'EOF' > manifest.yml applications: - name: demo-dynamodb buildpacks: - java_buildpack_offline memory: 768m path: ./target/demo-scylla-alternator-0.0.1-SNAPSHOT.jar env: JBP_CONFIG_OPEN_JDK_JRE: '{jre: {version: 17.+}}' SPRING_CLOUD_AWS_CREDENTIALS_STS_WEBIDENTITYTOKENFILE: /tmp/token SPRING_CLOUD_AWS_CREDENTIALS_STS_ROLEARN: arn:aws:iam::CHANGE_ME:role/cf-${vcap.application.organization_name}-${vcap.application.space_name} SPRING_CLOUD_AWS_CREDENTIALS_STS_ROLESESSIONNAME: ${vcap.application.application_name} SPRING_CLOUD_AWS_CREDENTIALS_STS_ASYNCCREDENTIALSUPDATE: true SPRING_CLOUD_AWS_DYNAMODB_TABLEPREFIX: ${vcap.application.organization_name}-${vcap.application.space_name}- SPRING_CLOUD_AWS_DYNAMODB_ENDPOINT: "" AWS_REGION: ap-northeast-1 sidecars: - name: issue-token command: | APP_DOMAIN=$(echo $VCAP_APPLICATION | jq -r '.application_uris[0] | split(".")[1:] | join(".")') while true;do set +e curl -s -XPOST https://cits.${APP_DOMAIN}/token \ --key $CF_INSTANCE_KEY \ --cert $CF_INSTANCE_CERT \ -o ${SPRING_CLOUD_AWS_CREDENTIALS_STS_WEBIDENTITYTOKENFILE} \ -w 'status:%{http_code}\n' set -e sleep 7200 done memory: 16m process_types: - web EOF sed -i.bk "s/CHANGE_ME/$(aws sts get-caller-identity --query Account --output text)/" manifest.yml rm -f manifest.yml.bk ``` `SPRING_CLOUD_AWS_DYNAMODB_TABLEPREFIX`でDynamoDBにアクセスするテーブルのprefixを設定します。 これをデプロイします。 ```bash cf push ``` 次のコマンドでログを確認します。 ```bash cf logs demo-dynamodb --recent ``` サイドカーでトークンが発行できていることがわかります。 ``` ... 2024-08-28T18:07:55.31+0900 [APP/PROC/WEB/SIDECAR/ISSUE-TOKEN/0] OUT status:200 ... ``` アプリにアクセスします。 ``` DEMO_DOMAIN=$(cf curl /v3/apps/$(cf app demo-dynamodb --guid)/routes | jq -r '.resources[0].url') curl https://$DEMO_DOMAIN/movies ``` 次のJSONが返り、アプリが無事DynamoDBにアクセスできていることがわかります。 ``` [{"movieId":"2a4b6d72-789b-4a1a-9c7f-74e5a8f7676d","title":"The Matrix","releaseYear":1999,"genre":"Action","rating":8.7,"director":"The Wachowskis"},{"movieId":"3f6c8f74-2e6a-48e9-a07f-034f8a67b9e6","title":"Interstellar","releaseYear":2014,"genre":"Adventure","rating":8.6,"director":"Christopher Nolan"},{"movieId":"1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5","title":"Inception","releaseYear":2010,"genre":"Science Fiction","rating":8.8,"director":"Christopher Nolan"}] ``` SDKの追加を除いて、アプリに手を入れることなく、AWS STSを使ってAWSリソースにアクセスできることが確認できました。 --- Cloud FoundryのInstance IdentityをWeb Identity Tokenに変換することで、IAMロールを使ったAWSリソースへのアクセスができることを確認しました。 この仕組みはAWSに依らず、[Github ActionsのOIDC連携](https://docs.github.com/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect)のように、他のクラウドサービスやHashiCorp Vaultの認証にも使えるでしょう。