AWSからGoogle Cloudにサービスアカウントを使わずにWorkload Identity連携する
はじめに
おはようございます、加藤です。Google Cloudに対してAWSなどの外部ワークロードからアクセスする場合はWorkload Identity連携を使用することでサービスアカウントキーを発行せずに認可が行う事ができます。キーを発行せずに済むことはセキュリティの向上とキーローテーションなどの運用負荷軽減に繋がり、多くのユースケースで推奨されます。
Workload Identity連携を行う際にはGoogle Cloud内の認可としてサービスアカウントを借用するものと理解していましたが、ドキュメントを確認するとサービスアカウントを借用せずにリソースへの直接アクセスが推奨されていました。この場合の手順が公式ドキュメント以外に見つけられなかったのでAWS CDKとTerraformを使い、Lambda関数からCloud Storageにオブジェクトの読み書きを実装した例を共有します。
今回の検証で作成したコードは、https://github.com/intercept6/aws-to-google-cloud-workload-identityで公開しています。
連携するにあたって決めること
リソースへの直接アクセス対応確認
リソースへの直接アクセスは一部のGoogle Cloudサービスでは制限があります。どのサービスに制限があるかはプロダクトと制限事項のリストで確認することができます。
今回はCloud Storageに対して認可を行いますが、いくつか制約があります。
バケットに対する書き込みと読み込みのみ行うのでバケットを均一なバケットレベルのアクセスで作成しなくてはならないという影響があります。
プリンシパルとして制約する要素
Workload Identity連携はプリンシパルの情報を照合することで実現されます。そのため、プリンシパルが何を満たせば認可するのかあらかじめ決めておく必要があります。AWSからGoogle Cloudにアクセスする場合は具体的に以下の要素で判断します。
- AWSアカウントID(必須)
- AWS IAMロール名
- AWS IAMロールセッション名(EC2インスタンスID、Lambda関数名など)
今回はAWSアカウントIDとAWS IAMロール名が期待する値と一致していればアクセスを認可します。AWS IAMロールセッション名まで制約をかけることは、①Google Cloud側がAWS側がどのAWSリソースでアクセスしてきているかを意識することになり、責務が混じり合ってしまう②リソースの作り直しやAutoScalingへの対応が出来ないので多くのユースケースでは不要と考えています。
AWS CDKでリソースを作成する際のベストプラクティスとして、物理名ではなく、生成されたリソース名を使用することがありますが、AWS IAMロール名が変更されると認可が出来ないので物理名を使用します。
Google Cloud側リソース作成
Cloud Storageバケットを作成します。前述のとおり、均一なバケットレベルのアクセスで作成する必要があるのでuniform_bucket_level_access = true
を設定します。
resource "google_storage_bucket" "bucket" {
name = var.bucket_name
location = "asia-northeast1"
storage_class = "STANDARD"
uniform_bucket_level_access = true
}
Workload Identity プールとプロバイダを作成します。プールに対してプロバイダは複数作成することが出来ますが、ドキュメントを確認するとWorkload Identity プールごとに 1 つのプロバイダを使用して、サブジェクトの競合を回避することが推奨されています。
ここでの要点はattribute_mapping
の設定です。全てのマッピングはassertion.arn
がベースとなり設定されます。AWSの場合はGet Caller Identityのレスポンスがここに入り、Assume Roleが発生する場合はarn:aws:sts::123456789012:assumed-role/my-role-name/my-role-session-name
という形式になります。
extract関数は値を抽出する関数なのでそれぞれの値は以下の様になります。
Name | Value |
---|---|
google.subject | arn:aws:sts::123456789012:assumed-role/my-role-name/my-role-session-name |
attribute.aws_role | my-role-name |
attribute.aws_session | my-role-session-name |
注意事項としてgoogle.subjectは127文字以内にする必要があります。IAM ロール名とLambda関数の場合は関数名をこの制約に収まるように設定してください。google.subject
へのマッピング時に加工して短くすることも出来ますが、この値はCloud Loggingに記録されるので、下手に加工せずに満たすように固定値を設定するのが好ましいと思います。
resource "google_iam_workload_identity_pool" "id_pool" {
workload_identity_pool_id = "my-id-pool"
}
resource "google_iam_workload_identity_pool_provider" "id_pool_provider" {
workload_identity_pool_id = google_iam_workload_identity_pool.id_pool.workload_identity_pool_id
workload_identity_pool_provider_id = "my-id-provider"
attribute_mapping = {
"google.subject" = "assertion.arn"
"attribute.aws_role" = "assertion.arn.extract('assumed-role/{role}/')"
"attribute.aws_session" = "assertion.arn.extract('assumed-role/{role_and_session}').extract('/{session}')"
}
aws {
account_id = var.aws_account_id
}
}
バケットに対する認可を作成します。読み込みと書き込みの為のロールを分けて作成するので認可も2つ作成します。ここでの要点はmembers
に設定する値です。
principalSet://iam.googleapis.com/projects/${data.google_project.project.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.id_pool.workload_identity_pool_id}/attribute.aws_role/${var.aws_writer_role_name}
という形式で設定する必要があります。
各所について説明します。
-
principalSet://iam.googleapis.com/
: 固定値を設定 -
projects/${data.google_project.project.number}/
: Google CloudプロジェクトのIDではなくプロジェクト番号を指定する、プロジェクトIDさえあれば、Terraformのデータソースから取得できます -
locations/global/
: 固定値を設定 -
workloadIdentityPools/${google_iam_workload_identity_pool.id_pool.workload_identity_pool_id}/
: プールのIDを指定する -
attribute.aws_role/${var.aws_writer_role_name}
: マッピングされたattribute.aws_role
に対して期待する値を設定する
data "google_project" "project" {
}
resource "google_storage_bucket_iam_binding" "writer_bucket_iam_binding" {
bucket = google_storage_bucket.bucket.name
# objectCreaterだとファイルの上書きができない
role = "roles/storage.objectUser"
members = [
"principalSet://iam.googleapis.com/projects/${data.google_project.project.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.id_pool.workload_identity_pool_id}/attribute.aws_role/${var.aws_writer_role_name}"
]
}
resource "google_storage_bucket_iam_binding" "reader_bucket_iam_binding" {
bucket = google_storage_bucket.bucket.name
role = "roles/storage.objectViewer"
members = [
"principalSet://iam.googleapis.com/projects/${data.google_project.project.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.id_pool.workload_identity_pool_id}/attribute.aws_role/${var.aws_reader_role_name}"
]
}
これでGoogle Cloud側リソースの定義は完了です。
デプロイしたらコンソールにアクセスし、IAMと管理 > Workload Identity連携 > プールを選択 > 接続済みサービスアカウント > 構成をダウンロード からコンフィグをダウンロードしておきます。
AWS側リソース作成
IAMロールを作成します。読み込みと書き込みの為に2つのロールを作成しています。ここでの要点はロール名を指定して作成することです。ECSからも使用できるようにしていますが、これは不要です。私がECSからのアクセスも検証した名残で設定しています。
const readerRole = new cdk.aws_iam.Role(this, "ReaderRole", {
roleName: "reader_role",
assumedBy: new cdk.aws_iam.CompositePrincipal(
new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"),
new cdk.aws_iam.ServicePrincipal("ecs-tasks.amazonaws.com")
),
managedPolicies: [
cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
),
],
});
const writerRole = new cdk.aws_iam.Role(this, "WriterRole", {
roleName: "writer_role",
assumedBy: new cdk.aws_iam.CompositePrincipal(
new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"),
new cdk.aws_iam.ServicePrincipal("ecs-tasks.amazonaws.com")
),
managedPolicies: [
cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
),
],
});
Lambda関数を作成します。読み込みと書き込みそれぞれのIAMロールを持つように作成します。前述の通り関数名はgoogle.subject
にマッピングされ、全体で127文字以下の要件があるので、CDKのベストプラクティスに反しますが固定の物理名を設定します。
new cdk.aws_lambda_nodejs.NodejsFunction(this, "WriterLambda", {
functionName: "writer_lambda",
entry: resolve(__dirname, "handler", "index.ts"),
handler: "writerHandler",
role: props.writerRole,
runtime: cdk.aws_lambda.Runtime.NODEJS_20_X,
logGroup: new cdk.aws_logs.LogGroup(this, "WriterLogGroup", {
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
environment: {
PROJECT_ID: process.env.TF_VAR_google_project_id!,
BUCKET_NAME: process.env.TF_VAR_bucket_name!,
},
});
new cdk.aws_lambda_nodejs.NodejsFunction(this, "ReaderLambda", {
functionName: "reader_lambda",
entry: resolve(__dirname, "handler", "index.ts"),
handler: "readerHandler",
role: props.readerRole,
runtime: cdk.aws_lambda.Runtime.NODEJS_20_X,
logGroup: new cdk.aws_logs.LogGroup(this, "ReaderLogGroup", {
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
environment: {
PROJECT_ID: process.env.TF_VAR_google_project_id!,
BUCKET_NAME: process.env.TF_VAR_bucket_name!,
},
});
検証用となので、Lambda関数のコードは読み込みと書き込みで同じものを利用します。ただし、エクスポートするハンドラー関数をそれぞれで分けています。
Google Cloudからダウンロードしたコンフィグはcredentials.json
にリネームして同じディレクトリに配置してください。このコンフィグはクレデンシャルを含まないのでリポジトリに保存して問題ありませんが、Google Cloudプロジェクト番号を含むのでパブリックには公開しないのが望ましいでしょう。
import { Storage } from "@google-cloud/storage";
import { Handler } from "aws-lambda";
import * as credentials from "./credentials.json";
const storage = new Storage({
projectId: process.env.PROJECT_ID,
credentials,
});
const FILE_NAME = "lambda.txt";
export const writerHandler: Handler = async () => {
await storage
.bucket(process.env.BUCKET_NAME!)
.file(FILE_NAME)
.save("Hello, World!");
};
export const readerHandler: Handler = async () => {
const contents = await storage
.bucket(process.env.BUCKET_NAME!)
.file(FILE_NAME)
.download();
console.log(contents.toString());
};
これでAWS側リソースの定義は完了です。
デプロイしましょう。
動作確認
Google CloudとAWSのデプロイが完了したら、AWSで書き込みLambda関数、読み込みLambda関数の順に実行しましょう。エラー無く実行され、Google CloudのCloud Storageにオブジェクトが作成されていれば確認完了です。
おまけ
ECS Fargateを使う場合の書き方
Google Cloudから提供されるコンフィグにはAWSリソースがメタデータにアクセスするためのURIが記載されています。EC2インスタンスやLambda関数はそのままで良いのですが、ECS Fargateを使う場合URIが異なり利用できません。これを回避するにはAwsSecurityCredentialsSupplier
を継承したクラスを独自定義し、Google Cloud SDKに与えることで解消できます。
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Storage } from "@google-cloud/storage";
import {
AwsClient,
AwsSecurityCredentials,
AwsSecurityCredentialsSupplier,
ExternalAccountSupplierContext,
} from "google-auth-library";
import * as credentials from "./credentials.json";
class AwsContainerSupplier implements AwsSecurityCredentialsSupplier {
async getAwsRegion(context: ExternalAccountSupplierContext): Promise<string> {
const res = await fetch(
`${process.env.ECS_CONTAINER_METADATA_URI_V4}/task`
);
// https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-metadata-endpoint-v4-fargate-examples.html
const metadata = await res.json();
// console.log(`successfully fetched metadata: ${JSON.stringify(metadata)}`);
const az = metadata.AvailabilityZone;
// console.log(`successfully fetched availability zone: ${az}`);
const region = az.slice(0, -1);
// console.log(`successfully parsed region from availability zone: ${region}`);
return region;
}
async getAwsSecurityCredentials(
context: ExternalAccountSupplierContext
): Promise<AwsSecurityCredentials> {
const res = await fetch(
`http://169.254.170.2${process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}`
);
// https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/security-iam-roles.html
const credentials = await res.json();
// console.log(
// `successfully fetched credentials: ${JSON.stringify(credentials)}`
// );
return {
accessKeyId: credentials.AccessKeyId,
secretAccessKey: credentials.SecretAccessKey,
token: credentials.Token,
};
}
}
const client = new AwsClient({
type: credentials.type,
audience: credentials.audience,
subject_token_type: credentials.subject_token_type,
token_url: credentials.token_url,
awsSecurityCredentialsSupplier: new AwsContainerSupplier(),
});
const storage = new Storage({
projectId: process.env.PROJECT_ID,
authClient: client,
});
const FILE_NAME = "ecs.txt";
const app = new Hono();
app
.get("/health", (c) => c.json({ status: "ok" }))
.post("/writer", async (c) => {
await storage
.bucket(process.env.BUCKET_NAME!)
.file(FILE_NAME)
.save("Hello, World!");
return c.text("File written!");
})
.get("/reader", async (c) => {
const contents = await storage
.bucket(process.env.BUCKET_NAME!)
.file(FILE_NAME)
.download();
return c.text(contents.toString());
});
const port = 3000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port,
});
あとがき
Google Cloudがサービスアカウントの作成ではなくリソースへの直接アクセスを推奨する理由は見つけられませんでしたが、Cloud Loggingでの追跡する際に1ステップ減ること作成するリソースが減ることが利点だと思います。サービスアカウントは単一目的のサービス アカウントを作成することが推奨されているので、これに従うとPool : Provider : Service account = 1 : 1 : 1
になり、サービスアカウントを使用するモチベーションは無くなります。
AWSとGoogle Cloudを連携する際の参考になれば幸いです。以上です!
Discussion