AWSでのサービスアクセスには、IAMユーザーよりもIAMロールの利用が推奨されています。IAMユーザーは固定のアクセスキーを使うので、漏洩や管理が大変です。
対して、IAMロールは一時的なトークンを使ってアクセスを制御できるため、セキュリティが高く柔軟です。必要なときだけアクセスを許可し、不要なときは簡単に取り消せるので、より安全で便利な方法です。
Cloud Foundry(本記事ではTanzu Application Serviceを想定)のアプリからAWSのサービスにアクセスするケースを考えます。
目次
- 実現方法
- CF Identity Token Serviceのデプロイ
- CF Identity Token ServiceをAWS IAMのOIDCプロバイダーとして登録する
- IAMロールの作成
- ポリシーの作成
- AWS CLIでアクセス
- サイドカーを使ってトークンを取得
実現方法
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という仕組みがあります。
中身はTLS証明書です。各コンテナに自身を証明する公開鍵が環境変数CF_INSTANCE_CERTのパス上に、秘密鍵がCF_INSTANCE_KEYのパス上に存在します。
cf sshでコンテナにログインして以下のコマンドを叩くと証明書の中身を確認できます。Diegoの認証局から証明書が発行されます。
$ 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を設定する方法はこちらの記事に書きましたが、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認証は次の図のようなフローになります。
(図はこちらの記事のものを参考にしました)
では実際に試してみましょう。
CF Identity Token Serviceのデプロイ
CF Identity Token ServiceはJWTを署名するためのRSAの公開鍵と秘密鍵が必要なので、次のコマンドで生成します。
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します。公開鍵と秘密鍵はここでは環境変数に設定します。
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.<apps_domain>
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%
デコードします。次のようなペイロードを確認できます。
$ 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が<org_guid>:<space_guid>:<app_guid>という形式になっています。
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を取得します。
# CITS_DOMAIN=cits.<apps_domain>
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 2>/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を登録します。
cat <<EOF > 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を取得します。
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を作成します。
# 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)
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のテーブルに対するアクセス権を与えましょう。アクセスできるテーブルは、テーブル名に<org_name>-<space_name>-のPrefixがついたものだけに限定するように次のポリシーを作成します。
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export AWS_REGION=ap-northeast-1
cat <<EOF > 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でログインします。
# CITS_DOMAIN=cits.<apps_domain>
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が返るでしょう。
{
"UserId": "****:cf-demo",
"Account": "****",
"Arn": "arn:aws:sts::****:assumed-role/cf-<org_name>-<space_name>/cf-demo"
}
DynamoDBにアクセスします。引き続き、コンテナ内で以下のコマンドを実行します。
Note
この記事で作成したテーブルを利用します。
TABLENAME=<org_name>-<space_name>-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アプリをデプロイする場合には、このトークンの取得及び更新はバックグランドで自動でやって欲しいです。
そこでトークン取得処理をサイドカーで実装します。
前例で作ったDynamoDBのテーブルにアクセスするアプリとしてこの記事で作成したSpring Bootアプリをデプロイします。
git clone https://github.com/making/demo-scylladb-alternator
cd demo-scylladb-alternator
AWS STSを使うためにはSDKの追加が必要ですので、pom.xmlに次のdependencyを追加します。
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sts</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
アプリをビルドします。
./mvnw clean package
次のmanifest.ymlを作成します。
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を設定します。
これをデプロイします。
cf push
次のコマンドでログを確認します。
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連携のように、他のクラウドサービスやHashiCorp Vaultの認証にも使えるでしょう。