投稿日:
更新日:

【DeepDive】Workload Identity Federation の仕組み

Authors

目次

banner.png

はじめに

マルチクラウド環境において、AWS から GCP のマネージドサービスに対し、OIDC によるキーレス連携を実現する GCP Workload Identity Federation の仕組みについて紹介します。

多くのクラウドサービスでは、API キーや Credential(AWS アクセスキーや GSA アカウントキー)を発行することで、各種マネージドサービスにアクセスすることが可能です。 しかし、永続的に使用できてしまう認証情報は、キーローテーションによる管理の煩雑化や、流出によるセキュリティリスクが存在します。

GCP Workload Identity Federation では、Credential として有効期限の短い STS トークンで認証・認可を行うため、前述のセキュリティリスクを軽減できます。 これらの処理はクライアントライブラリによって隠蔽されるため、サービスの開発者やワークロードは OAuth 2.0 Token Exchange の複雑な仕組みを意識することなく、通常の Credential と同じように利用することができます。

GCP Workload Identity Federation は非常にセキュアで利便性が良いですが、内部でどのような手続きが行われているのか気になったので DeepDive してみました。

本記事では、前提知識から紹介しているため、Workload Identity Federation の仕組みだけ知りたい方は こちら から読んでください。

事前知識

Workload Identity Federation

GCP User Guide - Workload Identity Federation

GCP Workload Identity Federation は OAuth 2.0 Token Exchange に従い、OIDC をサポートする外部プロバイダの STS(Security Token Service)に Credential を提供します。 通常、GCP の外部で実行されているアプリケーションは、GSA アカウントキーを使用して GCP リソースにアクセスできますが、永続的に使用可能な認証情報を利用することでセキュリティリスクが生じます。

Workload Identity Federation では、GCP IAM を使用して外部のアイデンティティに IAM ロールを付与し、GCP リソースへの直接的なアクセスを許可できます。 この仕組みを利用してオンプレミスや他クラウドの外部アイデンティティに GSA アカウントキーを使用せずに GCP リソースへのアクセス権を付与できます。

具体的には、呼び出し元となる AWS の IAM ロールに対し、呼び出し先となる GSA になりすますことができるロール(roles/iam.workloadIdentityUser)を付与します。 呼び出し元となる AWS のワークロードは、自身の IAM ロールを使用して STS トークン を取得し、有効期限の短い GSA アクセストークン(インパーソネーショントークン)と交換します。

最後に、AWS のワークロードは GSA アクセストークンを借用し、GSA に impersonate して(= なりすまして)GCP リソースにアクセスします。

Workload Identity Federation には Workload Identity Pool と Workload Identity Pool Provider という概念・機能が存在します。

  • Workload Identity Pool
    • 外部 IdP をグルーピングして管理するコンポーネント
    • 1 つの GCP プロジェクトに複数の Workload Identity Pool を作成できる
  • Workload Identity Pool Provider
    • GCP と外部 IdP(AWS)との関係を表すエンティティ(一意に識別可能なサービス情報の定義)
    • 1 つの Workload Identity Pool に複数の Workload Identity Pool Provider を作成できる

workload-Identity-federation.png

OAuth 2.0 Token Exchange

RFC 8693 - OAuth 2.0 Token Exchange

OAuth 2.0 Token Exchange は、OAuth 2.0 プロトコルにおける拡張仕様で、認可サーバ間でアクセストークンや ID トークン等の認可情報を交換するための基本的な手続きを提供します。

特に、分散システムやマイクロサービス環境では、都度、認証キーを発行することで管理の煩雑化やセキュリティリスクの懸念があります。

OAuth 2.0 Token Exchange を利用することで、複数の異なるシステムやサービス間において、トークンベースで認証・認可が行えるためセキュアなキーレス連携が可能になります。 また、RFC で標準化されているため、異なる実装間において互換性を確保することが可能です。

oauth2-token-exchange.png

  1. 認可サーバ(IdP)間の信頼

    交換元となるトークンを発行する IdP(Relying-party IdP)と、交換をリクエストする IdP(Third-party IdP)は信頼関係にあります。

  2. クライアントが Relying-party IdP にトークンをリクエスト

    交換元となるトークンを発行する IdP のトークンエンドポイントに認可トークンの発行をリクエストします。

  3. Relying-party IdP が認可トークンを発行

    クライアントを認可してアクセストークンまたは ID トークンを発行します。

  4. クライアントが Third-party IdP にトークン交換リクエストを送信

    リクエスト当事者のアイデンティティを表すサブジェクトトークンを含めて、別の認可サーバが準備しているトークン交換エンドポイントにリクエストします。

  5. 認可サーバがリクエストを検証

    クライアントから受け入れたトークンの検証や、交換後のトークンに適用するポリシを確認します。

  6. 新しいトークンの発行

    検証が成功すると、新しい形式のトークンやスコープが拡張されたトークンが発行され、クライアントに返されます。

【Kubernetes】Pod トークン

Pod がデプロイされると、Kubernetes API サーバ(Control-Plane)によってデフォルトのサービスアカウント(Default KSA)が発行されます。 Pod は、ワーカーノードで稼働する kubelet を通じて Kubernetes API にアクセスする際に、Default KSA を通じて認証トークンを引き受けます。 認証トークンは Pod が起動した際に、コンテナ内の /var/run/secrets/kubernetes.io/serviceaccount/token にマウントされます。

### Default の KSA
$ kubectl get serviceaccount default -n [名前空間]
NAME      SECRETS   AGE
default   0         14d

### Default の KSA トークンを確認
$ DEFAULT_KSA_TOKEN=$(kubectl exec -it [POD_NAME] -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
$ jwt decode $DEFAULT_KSA_TOKEN
{
  "aud": ["https://kubernetes.default.svc"],
  "exp": 1753085237,
  "iat": 1721549237,
  "iss": "https://oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]", // EKS の場合
  "kubernetes.io": {
    "namespace": "[名前空間]",
    "pod": {
      "name": "[Pod 名]",
      "uid": "baefc91e-980b-42c0-961d-dcfe08f117b3"
    },
    "serviceaccount": {
      "name": "[KSA 名]",
      "uid": "b5b8b845-b463-4557-b668-208716fa990f"
    },
    "warnafter": 1721552844
  },
  "nbf": 1721549237,
  "sub": "system:serviceaccount:[名前空間]:[KSA 名]"
}

JWT のペイロードを確認すると、トークンの発行者(Issuer)は Kubernetes Control-Plane の OIDC Provider であり、トークンの対象者(Audience)が https://kubernetes.default.svc であることが分かります。 ここで、https://kubernetes.default.svc は、Kubernetes API サーバへのアクセスに使用されるクラスタ内のアドレスです。

セキュリティ上の理由から、Pod 内のワークロードが Kubernetes API サーバへの呼び出しを行わない場合は、デプロイの際に Default KSA および JWT を発行しないようにすることも可能です。 このようなケースでは、Pod を作成する際、Pod の spec フィールドに automountServiceAccountToken: false を指定します。

【AWS】IAM Roles for Service Accounts

AWS User Guide - IAM roles for service accounts

IAM Roles for Service Accounts(IRSA)とは、EKS クラスタ内の KSA が、IAM ロールを引き受けるための機能です。 IRSA は Pod に対して、IAM ロールを定義した KSA をアタッチすることで利用できます。

IRSA を利用することで、Pod 用のアカウントキーおよびシークレットキーを発行することなく、AWS リソースにセキュアにアクセスすることが可能になります。

irsa.png

IAM ロールと KSA の 紐付け

  1. IAM ロールの ARN を annotations に指定した KSA を Pod に付与
apiVersion: v1
kind: ServiceAccount
metadata:
  name: [KSA 名]
  namespace: [名前空間]
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::[AWS_ACCOUNT_ID]:role/[ROLE_NAME]

認証

  1. Pod がデプロイされると EKS OIDC Provider が発行する JWT が Mutating Webhook を介して /var/run/secrets/eks.amazonaws.com/serviceaccount/token にマウントされる
### JWT(OIDC トークン)の確認
$ OIDC_TOKEN=$(kubectl exec -it [POD_NAME] -- cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token)
$ jwt decode $OIDC_TOKEN
{
  "aud": ["sts.amazonaws.com"], // トークン対象者(トークン自体は Pod が持つが、その対象が STS であることが分かる)
  "exp": 1721599908,
  "iat": 1721513508,
  "iss": "https://oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]", // トークン発行者
  "kubernetes.io": {
    "namespace": "[名前空間]",
    "pod": {
      "name": "[Pod 名]",
      "uid": "baefc91e-980b-42c0-961d-dcfe08f117b3"
    },
    "serviceaccount": {
      "name": "[KSA 名]",
      "uid": "b5b8b845-b463-4557-b668-208716fa990f"
    }
  },
  "nbf": 1721513508,
  "sub": "system:serviceaccount:[名前空間]:[KSA 名]"
}
  1. Pod の JWT と IAM ロール ARN を STS API エンドポイントに渡してアクセストークンをリクエスト
  2. STS サービスは Pod から受け取った JWT と IAM ロール ARN を IAM に渡して、一時的な認証情報(トークン)をリクエスト
  3. IAM は EKS OIDC Provider の JWKS(JSON Web Key Set)URL から公開鍵を取得して /.well-known/openid-configuration で検証
### EKS OIDC Provider から IdP の情報を取得
$ IDP=$(aws eks describe-cluster --name [CLUSTER_NAME] --query cluster.identity.oidc.issuer --output text)
$ curl -s $IDP/.well-known/openid-configuration | jq -r '.'
{
  "issuer": "https://oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]", // JWT の発行者(EKS OIDC Provider)
  "jwks_uri": "https://oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]/keys", // 公開鍵を取得するためのエンドポイント
  "authorization_endpoint": "urn:kubernetes:programmatic_authorization", // 認可エンドポイント
  "response_types_supported": ["id_token"], // OIDC Provider がサポートするレスポンスタイプ(ID トークン)
  "subject_types_supported": ["public"], // サポートされているサブジェクトタイプ
  "claims_supported": ["sub", "iss"], // OIDC Provider がサポートするクレーム
  "id_token_signing_alg_values_supported": ["RS256"] // ID トークンの署名に使用されるアルゴリズム
}
### JWKS URL から取得される公開鍵を確認
$ curl -s $IDP/keys | jq -r '.'
{
  "keys": [
    {
      "kty": "RSA", // Key Type:公開鍵暗号方式の種類
      "kid": "b12d2f264e3eb3036bde33008066f24f9eafa28e", // Key ID:公開鍵の識別子
      "use": "sig", // Public Key Use:鍵の用途(sig:デジタル署名)
      "alg": "RS256", // Algorithm:鍵が使用されるアルゴリズム(RS256:RSA SHA-256)
      "n": "xxx", // Modulus:RSA 公開鍵のモジュラス
      "e": "AQAB" // Exponent:RSA 公開鍵の指数部分
    },
    (以下省略)
  ]
}
  1. IAM ロールに基づいてリソースへのアクセスを許可
  2. STS サービスがアクセストークンを発行して Pod に渡す

認可

  1. Pod は arn:aws:iam::[AWS_ACCOUNT_ID]:role/[ROLE_NAME] に設定された IAM ロールのアクセストークン(STS トークン)およびロールを使用して AWS リソースにアクセス

【GCP】Workload Identity

GCP User Guide - Authenticate to Google Cloud APIs from GKE workloads

Workload Identity とは、GKE クラスタ内の KSA が、GSA および IAM ロールを引き受けるための機能です。 Workload Identity は Pod に対して、GSA を定義した KSA をアタッチすることで利用できます。

Workload Identity を利用することで、Pod 用の GSA アカウントキーを発行することなく、GCP リソースにセキュアにアクセスすることが可能になります。

Workload Identity が GKE クラスタで有効化されると、gke-metadata-server という DaemonSet がデプロイされます。(DaemonSet なので全ての GKE Node で起動する)
gke-metadata-server は、その名の通り metadata を扱うサーバであり、Workload Identity を利用する上で必要な手続きを実行します。

$ kubectl get daemonset -n kube-system | grep -E 'NAME|gke-metadata-server'
NAME                                     DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR                                                        AGE
gke-metadata-server                      6         6         6       6            6           iam.gke.io/gke-metadata-server-enabled=true,kubernetes.io/os=linux   2y21d

Workload Identity が有効化されると、全ての GKE Node に以下の iptables(chain rule)が追加され、Pod からの通信は、DNAT によって gke-metadata-server でプロキシされます。

$ iptables-legacy -L -n -v -t nat
Chain PREROUTING (policy ACCEPT 2655 packets, 160K bytes)
 pkts bytes target          prot opt in     out     source               destination
 129M 9016M KUBE-SERVICES   all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service portals */
    0     0 DNAT            tcp  --  *      *       0.0.0.0/0            169.254.169.254      tcp dpt:8080 /* metadata-concealment: bridge traffic to metadata server goes to metadata proxy */ to:127.0.0.1:987
45715 2743K DNAT            tcp  --  *      *       0.0.0.0/0            169.254.169.254      tcp dpt:80 /* metadata-concealment: bridge traffic to metadata server goes to metadata proxy */ to:127.0.0.1:988
...

gke-metadata-server により、ポート 987 番 および 988 番が Node の HostPort を使ってリッスンしています。

Pod は、同じワーカーノードにデプロイされた gke-metadata-server にアクセスすることにより、GSA のアクセストークンを取得することができます。

※Workload Identity の仕組みに関しては Google Cloud Next'19 で紹介されています。

workload-identity.png

GSA*1GSA*2
形式[GSA 名]@PROJECT_ID.iam.gserviceaccount.comPROJECT_ID@svc.id.goog[[名前空間]/[KSA 名]]
作成GCP IAM を使用して作成GKE OIDC Provider が KSA JWT を発行して GCP プロジェクトの Workload Identity と紐付ける
管理IAM ロールによるプロジェクト全体での権限管理RBAC によるクラスタ内での権限管理
利用用途GCP リソースへの直接アクセスKSA を GSA と紐付ける
(Workload Identity の利用)
権限範囲GCP プロジェクト全体(GKE Pod が GCP リソースへのアクセス権を取得するための手段でしかない)

GSA と KSA の 紐付け

  1. GSA を annotations に指定した KSA を Pod に付与
apiVersion: v1
kind: ServiceAccount
metadata:
  name: [KSA 名]
  namespace: [名前空間]
  annotations:
    iam.gke.io/gcp-service-account: [GSA 名]@[PROJECT_ID].iam.gserviceaccount.com

認証

  1. Pod のクライアントライブラリ(GCP SDK)が gke-metedata-server に GCP リソースにアクセスするための GSA アクセストークンをリクエスト
  2. gke-metedata-server は Pod が使用している KSA を見つけ、annotations に設定された iam.gke.io/gcp-service-account: [GSA 名]@PROJECT_ID.iam.gserviceaccount.com (※1) を取得
  3. gke-metedata-server は GKE OIDC Provider から OIDC 署名付き JWT を取得(GKE Node の証明書を使用して mTLS 通信 で取得)
  4. gke-metadata-server は OIDC 署名付き JWT を IAM に渡す
  5. IAM は GKE OIDC Provider に問い合わせて JWT を検証(GSA*2 と OIDC Provider の紐付け確認)
  6. IAM は PROJECT_ID.svc.id.goog[[名前空間]/[KSA 名]] (※2) のアクセストークンを発行

OAuth 2.0 Token Exchange

GKE IdP(PROJECT_ID.svc.id.goog[[名前空間]/[KSA 名]] (※2))が発行したトークンを使用して、IAM IdP(iam.gke.io/gcp-service-account: [GSA 名]@PROJECT_ID.iam.gserviceaccount.com (※1))とトークンを交換する

  1. gke-metadata-server は PROJECT_ID.svc.id.goog[[名前空間]/[KSA 名]] (※2) のアクセストークンを 再度 IAM に渡す
  2. IAM は PROJECT_ID.svc.id.goog[[名前空間]/[KSA 名]] (※2) のアクセストークンと引き換えに Binding 情報に基づいて iam.gke.io/gcp-service-account: [GSA 名]@PROJECT_ID.iam.gserviceaccount.com (※1) のアクセストークンを渡す
  3. gke-metadata-server は iam.gke.io/gcp-service-account: [GSA 名]@PROJECT_ID.iam.gserviceaccount.com (※1) のアクセストークン を Pod に渡す

認可

  1. Pod は iam.gke.io/gcp-service-account: [GSA 名]@PROJECT_ID.iam.gserviceaccount.com (※1) に設定された GSA アクセストークンおよびロールを使用して GCP リソースにアクセス

※ IRSA との主な違い

EKS では、Pod がデプロイされた際に、Mutating Webhook により予め EKS OIDC Provider が発行した OIDC トークンが付与されるが、GKE では Pod がリソースにアクセスする際に、gke-metadata-server を介して認証の手続きを行う。


Workload Identity Federation の流れ

AWS EKS のワークロード(Pod)から GCP マネージドサービスへの Workload Identity Federation を例に、内部の挙動を確認します。

IRSA および OAuth 2.0 Token Exchange の仕組みを理解していることが前提となります。

gcp-workload-identity-federation.png

Workload Identity Federation の設定

GCP

  1. GCP Workload Federation に Workload Identity Pool を作成

workload-identity-pool.png

IAM Principal:

principal://iam.googleapis.com/projects/[GCP_PROJECT_NUMBER]/locations/global/workloadIdentityPools/[GCP_WIF_POOL_ID]/subject/SUBJECT_ATTRIBUTE_VALUE`
  1. Workload Identity Pool に Workload Identity Pool Provider を作成して EKS OIDC Provider を登録

workload-identity-pool-provider.png

  • Issuer(URL):https://oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]
    • EKS OIDC Provider エンドポイントを追加
  • Allowed audiences:sts.amazonaws.com
    • STS によって発行されたトークンを対象(許可)とする
  • Attribute Mapping:google.subject - assertion.subCEL 式で指定)
    • EKS OIDC Provider が発行した OIDC トークン(JWT)の sub フィールドを google.subject にマッピング
    • 【FYI】EKS OIDC Provider が発行する JWT
      {
        "aud": ["sts.amazonaws.com"], // トークン対象者(トークン自体は Pod が持つが、その対象が STS であることが分かる)
        "exp": 1721599908,
        "iat": 1721513508,
        "iss": "https://oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]", // トークン発行者
        "kubernetes.io": {
          "namespace": "[名前空間]",
          "pod": {
            "name": "[Pod 名]",
            "uid": "baefc91e-980b-42c0-961d-dcfe08f117b3"
          },
          "serviceaccount": {
            "name": "[KSA 名]",
            "uid": "b5b8b845-b463-4557-b668-208716fa990f"
          }
        },
        "nbf": 1721513508,
        "sub": "system:serviceaccount:[名前空間]:[KSA 名]"
      }
      
  1. GSA に roles/iam.workloadIdentityUser と GCP リソースのアクセスに必要な IAM ロール(例:roles/storage.objectViewer)を付与して Workload Identity User を作成
### GSA を定義
resource "google_service_account" "default" {
  project      = "[GCP_PROJECT_ID]"
  account_id   = "[GSA_NAME]"
  display_name = "[GSA_NAME]"
  description  = "Managed by terraform: [GSA_NAME]"
}

### GSA に プロジェクトレベルで IAM ロールを付与
resource "google_project_iam_member" "default" {
  project = "[GCP_PROJECT_ID]"
  member  = "serviceAccount:${google_service_account.default.email}"
  for_each = toset([
    "roles/storage.objectViewer" # Storage Object Viewer
  ])
  role = each.value
}

### GSA の所定の Principal に対して Workload Identity User 権限を付与
resource "google_service_account_iam_member" "default" {
  for_each = toset([
    "principal://iam.googleapis.com/projects/408046229787/locations/global/workloadIdentityPools/[GCP_WIF_POOL_ID]/subject/system:serviceaccount:[名前空間]:[KSA 名]",
  ])
  service_account_id = google_service_account.default.id
  role               = "roles/iam.workloadIdentityUser"
  member             = each.value
}

上記の Terraform を実行すると、GSA が作成されて Workload Identity Pool に紐付きます。(iam-policy-binding というやつです)
GSA に Principal を指定しておくことで、Workload Identity Pool が属性マッピングによって roles/iam.workloadIdentityUser ロールを Pod(KSA)に紐付けます。

  1. Workload Identity Pool に紐付けられた GSA の認証プロファイル(gcp.json)をダウンロード

credential-profile.png credential-profile-download.png

  • Provider:EKS クラスタ名
  • OIDC ID token path:/var/run/secrets/eks.amazonaws.com/serviceaccount/token
  • Format type:text

認証プロファイル(gcp.json)の中身

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/[GCP_PROJECT_NUMBER]/locations/global/workloadIdentityPools/[GCP_WIF_POOL_ID]/providers/[GCP_WIF_POOL_PROVIDER_ID]",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[GCP_GSA_EMAIL_ADDRESS]:generateAccessToken",
  "credential_source": {
    "file": "/var/run/secrets/eks.amazonaws.com/serviceaccount/token",
    "format": {
      "type": "text"
    }
  }
}
  • "type"
    • 認証方式が外部アカウントであることを示す
    • クライアントライブラリでは、この情報をもとに外部認証情報を使用してトークンを取得する
  • "audience"
    • Workload Identity Pool Provider 情報を示す
    • 後続の処理( ⑬ )において、どの Workload Identity Pool および Workload Identity Pool Provider に対して認証を行うかを特定する
  • "subject_token_type"
    • トークンの種類を示す
    • urn:ietf:params:oauth:token-type:jwt では JWT を使用する
  • "token_url"
    • トークンを交換するための GCP STS API エンドポイント を示す
    • GCP STS API エンドポイントにリクエストを送ることで、GCP リソースにアクセスするための一時的な認証情報(Google OAuth 2.0 Access Token)を受け取ることができる
  • "service_account_impersonation_url"
    • Workload Identity Federation を介してなりすます先の GSA の URL を指定する
    • Principal は、指定している GSA の IAM ロールを使用して GCP リソースにアクセスすることが可能となる
  • "credential_source":
    • どの方法で認証情報を取得するかを指定する
    • "file": "/var/run/secrets/eks.amazonaws.com/serviceaccount/token" は認証トークンが格納されているファイルパスを指定する
    • 今回の例では、EKS Pod の OIDC トークンを認証情報として使用する

AWS

  1. 認証プロファイル(gcp.json)を ConfigMap として Pod に付与
apiVersion: v1
kind: ConfigMap
metadata:
  name: gcp-credential
  namespace: [名前空間]
data:
  gcp.json: |
    {
      "type": "external_account",
      "audience": "//iam.googleapis.com/projects/[GCP_PROJECT_NUMBER]/locations/global/workloadIdentityPools/[GCP_WIF_POOL_ID]/providers/[GCP_WIF_POOL_PROVIDER_ID]",
      "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
      "token_url": "https://sts.googleapis.com/v1/token",
      "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[GCP_GSA_EMAIL_ADDRESS]:generateAccessToken",
      "credential_source": {
        "file": "/var/run/secrets/eks.amazonaws.com/serviceaccount/token",
        "format": {
          "type": "text"
        }
      }
    }
  1. GCP STS API と OAuth 2.0 Token Exchange を行うための IAM ロール(IRSA)を準備

IAM ロールに付与された以下のロール(IRSA)によって EKS OIDC Provider から取得された ID トークンを受け入れるように設定しておきます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::[AWS_ACCOUNT_ID]]:oidc-provider/oidc.eks.[REGION].amazonaws.com/id/[PROVIDER_ID]"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.ap-northeast-1.amazonaws.com/id/[PROVIDER_ID]:sub": "system:serviceaccount:[名前空間]:[KSA 名]",
          "oidc.eks.ap-northeast-1.amazonaws.com/id/[PROVIDER_ID]:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

EKS Pod から AWS リソースへのアクセスが特に必要無ければ IAM Policy は不要

IAM ロールと KSA の 紐付け

  1. IAM ロールの ARN を annotations に指定した KSA を Pod に付与
apiVersion: v1
kind: ServiceAccount
metadata:
  name: [KSA 名]
  namespace: [名前空間]
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::[AWS_ACCOUNT_ID]:role/[ROLE_NAME]

認証

  1. Pod がデプロイされると EKS OIDC Provider が発行する JWT が Mutating Webhook を介して /var/run/secrets/eks.amazonaws.com/serviceaccount/token にマウントされる
  2. Pod の JWT と IAM ロール ARN を AWS STS サービスを経由して IAM に渡す

より具体的には、STS を使用して IAM の AssumeRoleWithWebIdentity アクションを実行して一時的な AWS 認証情報(STS トークン)をリクエストする。

  1. IAM は EKS OIDC Provider の JWKS URL から公開鍵を取得して /.well-known/openid-configuration で検証
  2. IAM は AWS STS サービスを通じて、AWS の一時的な認証情報(STS トークン)を Pod に付与

OAuth 2.0 Token Exchange

EKS IdP が発行した OIDC トークンを使用して、最終的に GCP IAM IdP が発行した GSA のアクセストークンと交換する

  1. AWS の認証情報(STS トークン)を使用して GCP STS API(https://sts.googleapis.com/v1/token)にトークン交換リクエストを送信
KSA_TOKEN_PATH="/var/run/secrets/eks.amazonaws.com/serviceacrcount/token"
KSA_TOKEN=$(cat $KSA_TOKEN_PATH)

response=$(curl -s -X POST \
  -H "Content-Type: application/json" \
  -d "{
    \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\",
    \"requested_token_type\": \"urn:ietf:params:oauth:token-type:access_token\",
    \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",
    \"subject_token\": \"$KSA_TOKEN\",
    \"scope\": \"https://www.googleapis.com/auth/cloud-platform\",
    \"audience\": \"//iam.googleapis.com/projects/[GCP_PROJECT_NUMBER]/locations/global/workloadIdentityPools/[GCP_WIF_POOL_ID]/providers/[GCP_WIF_POOL_PROVIDER_ID]\"
  }" \
  "https://sts.googleapis.com/v1/token")

実際の OAuth 2.0 Token Exchange 処理は GCP が提供するクライアントライブラリ内で行われます。

また、必要な情報は Pod にマウントした ConfigMap(gcp.json)から取得されます。

  1. GCP STS サービスは Workload Identity Pool に問い合わせて AWS STS トークンを検証
  • EKS が発行した OIDC トークン(/var/run/secrets/eks.amazonaws.com/serviceacrcount/token)を確認
  • Workload Identity Pool および Workload Identity Pool Provider を確認
  1. Workload Identity Pool に指定されている IAM Principal の情報を使用して検証してトークン交換リクエストを許可
  2. Google OAuth 2.0 Access Token(GCP リソースにアクセスするための一時的な認証情報)を Pod に返送

https://cloud.google.com/iam/docs/workload-identity-federation#access_management

The token exchange flow returns a federated access token. You can use this federated access token to grant your workload access on behalf of principal identities on Google Cloud resources and obtain a short-lived OAuth 2.0 access token. You can use this access token to provide IAM access. We recommend that you use Workload Identity Federation to provide access directly to a Google Cloud resource. Although most Google Cloud APIs support Workload Identity Federation, some APIs have limitations. As an alternative, you can use service account impersonation. The short-lived access token lets you call any Google Cloud APIs that the resource or service account has access to.

  • Google OAuth2 Access Token は GCP リソースに対する認証とアクセス制御を行うために使用される一時的な認証情報(トークン)である。
  • GCP STS は Credential 情報(トークン)の交換と発行を行うが、特定の GSA への直接的な制御(GSA の偽装)は IAM Credential API によって処理される。
  • そのため、AWS STS トークンを元に直接、GSA トークンを発行することはできない。
  1. Google OAuth 2.0 Access Token を使用して IAM Credentials API に GSA アクセストークンをリクエスト
OAUTH2_TOKEN=$(echo $response | jq -r '.access_token')
REQUEST_BODY=$(
  cat <<EOF
{
  "scope": ["https://www.googleapis.com/auth/cloud-platform"]
}
EOF
)
response=$(curl -s -X POST \
  -H "Authorization: Bearer $OAUTH2_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$REQUEST_BODY" \
  "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[GCP_GSA_EMAIL_ADDRESS]:generateAccessToken")
  1. IAM は GSA に付与されているプロジェクトレベルの IAM ロール(例:roles/storage.objectViewer)を確認してアクセストークンを付与

この時発行されるアクセストークンは、Pod が GSA に impersonate して(= なりすまして)使用するトークンであることから、インパーソネーショントークン(Impersonate Token)とも呼ばれます。

認可

  1. Pod は インパーソネーショントークン(GSA アクセストークン)を使用して GCP リソースにアクセス

Pod は GSA の IAM ロールを引き受けて、GCP リソースにアクセスします。

ShellScript による挙動確認

以下のスクリプトを使用して OAuth 2.0 Token Exchange の動作を確認してみます。

EKS OIDC Provider から発行された OIDC トークンを使用して、GCP IAM と OAuth 2.0 Token Exchange を行い、GSA になりすまして GCS にアクセスする例です。

#!/bin/bash

set -e

GCP_PROJECT_NUMBER=
WORKLOAD_IDENTITY_POOL_ID=
WORKLOAD_IDENTITY_PROVIDER_ID=
GSA_EMAIL=
KSA_TOKEN_PATH="/var/run/secrets/eks.amazonaws.com/serviceaccount/token"

# 1. OIDC トークンを用して Google OAuth 2.0 Access Token の取得
KSA_TOKEN=$(cat $KSA_TOKEN_PATH)
response=$(curl -s -X POST \
  -H "Content-Type: application/json" \
  -d "{
    \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\",
    \"requested_token_type\": \"urn:ietf:params:oauth:token-type:access_token\",
    \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",
    \"subject_token\": \"$KSA_TOKEN\",
    \"scope\": \"https://www.googleapis.com/auth/cloud-platform\",
    \"audience\": \"//iam.googleapis.com/projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_IDENTITY_POOL_ID/providers/$WORKLOAD_IDENTITY_PROVIDER_ID\"
  }" \
  "https://sts.googleapis.com/v1/token")

echo "[INFO] Token Exchange Response: $response"

# 2. Google OAuth 2.0 Access Tokenの抽出
OAUTH2_TOKEN=$(echo $response | jq -r '.access_token')
echo "[INFO] Google OAuth 2.0 Access Token: $OAUTH2_TOKEN"

# 3. IAM Credentials API にリクエストを送信して GSA のアクセストークンを取得
REQUEST_BODY=$(
  cat <<EOF
{
  "scope": ["https://www.googleapis.com/auth/cloud-platform"]
}
EOF
)

response=$(curl -s -X POST \
  -H "Authorization: Bearer $OAUTH2_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$REQUEST_BODY" \
  "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$GSA_EMAIL:generateAccessToken")

echo "[INFO] IAMCredentials API Response: $response"

# 4. GSA アクセストークンの抽出
GSA_ACCESS_TOKEN=$(echo $response | jq -r '.accessToken')
echo "[INFO] GSA Token: $GSA_ACCESS_TOKEN"

# 5. GSA アクセストークンを使用して GCS にアクセス
BUCKET_NAME="ren510dev"
response=$(curl -s -X GET \
  -H "Authorization: Bearer $GSA_ACCESS_TOKEN" \
  "https://storage.googleapis.com/storage/v1/b/$BUCKET_NAME/o")

echo "[INFO] Objects in bucket:"
echo "$response" | jq -r '.items[] | [.name, .size] | @tsv'

スクリプトの概要

  1. EKS OIDC Provider が発行した OIDC トークンを使用して AWS STS から一時的な認証情報を取得し、AWS の認証情報を GCP STS API エンドポイント https://sts.googleapis.com/v1/token に送信して、Google OAuth 2.0 Access Token をリクエスト(OAuth 2.0 Token Exchange)
  2. レスポンスをパースして Google OAuth 2.0 Access Token を抽出
  3. GCP STS から発行された Google OAuth 2.0 Access Token を用いて IAM Credentials API にリクエストを送信し、GSA アクセストークンを取得
  4. レスポンスをパースして GSA アクセストークンを抽出
  5. GSA のアクセストークン(インパーソネーショントークン)を用いて GCS にアクセス

実行結果

### EKS Pod の中でスクリプトを実行します
root@wif-sample-5ccd44995-sjzsd:~# ./check.sh

### 1 の出力結果
[INFO] Token Exchange Response: {
  "access_token": "ya29.d.c0AY_VpXXXXXXXXXXXX",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 3599
}

### 2 の出力結果
[INFO] Google OAuth 2.0 Access Token: ya29.d.c0AY_VpXXXXXXXXXXXX

### 3 の出力結果
[INFO] IAMCredentials API Response: {
  "accessToken": "ya29.c.c0AY_VpXXXXXXXXXXXX",
  "expireTime": "2024-07-07T08:40:01Z"
}

### 4 の出力結果
[INFO] GSA Token: ya29.c.c0AY_VpXXXXXXXXXXXX

### 5 の出力結果
[INFO] Objects in bucket:
sample.txt	17
sample2.txt	17

EKS Pod は OAuth 2.0 Token Exchange によって GSA アクセストークンを取得し、GCS にアクセスできていることが分かります。

なお、1, 2 の段階で Google OAuth 2.0 Access Token が正しく発行されていないと、以下のようなエラーが返却され、GCP リソースに対してアクセス(GSA アクセストークンを取得)することはできません。

oauth2/google: status code 403: {\n \"error\": {\n   \"code\": 403,\n   \"message\": \"Permission 'iam.serviceAccounts.getAccessToken' denied on resource

workload-identity-federation-troubleshooting.png

このようなエラーが発生した場合、GSA Principal の設定を見直してみると良いかもしれません。

クライアントライブラリによる抽象化

クライアントライブラリとして GCP の Go SDK を使用します。

予めに、GCP の認証プロファイルを Workload Identity Pool からダウンロードし、ConfigMap として Pod にマウントしておきます。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: [KSA 名]
  namespace: [名前空間]
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::[AWS_ACCOUNT_ID]:role/[ROLE_NAME]
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: gcp-credential
  namespace: [名前空間]
data:
  gcp.json: |
    {
      "type": "external_account",
      "audience": "//iam.googleapis.com/projects/[GCP_PROJECT_NUMBER]/locations/global/workloadIdentityPools/[GCP_WIF_POOL_ID]/providers/[GCP_WIF_POOL_PROVIDER_ID]",
      "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
      "token_url": "https://sts.googleapis.com/v1/token",
      "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[GCP_GSA_EMAIL_ADDRESS]:generateAccessToken",
      "credential_source": {
        "file": "/var/run/secrets/eks.amazonaws.com/serviceaccount/token",
        "format": {
          "type": "text"
        }
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: [デプロイメント名]
  namespace: [名前空間]
spec:
  selector:
    matchLabels:
      app: [Pod 名]
  replicas: 1
  template:
    metadata:
      labels:
        app: [Pod 名]
    spec:
      serviceAccountName: [KSA 名]
      containers:
        - name: [コンテナ名]
          image: golang:latest
          imagePullPolicy: Always
          command: ['sleep', 'infinity']
          env:
            - name: GOOGLE_APPLICATION_CREDENTIALS # この環境変数を指定することで SDK はデフォルトで指定された JSON を認証情報として使用
              value: /credentials/gcp.json # ConfigMap から配置した JSON のパスを GOOGLE_APPLICATION_CREDENTIALS へ登録
          volumeMounts:
            - name: gcp-credential
              mountPath: /credentials
      volumes:
        - name: gcp-credential
          configMap:
            name: gcp-credential

github.com/golang/oauth2/google/default.go を確認すると、認証情報の取得順序は次のようになっています。

  1. 環境変数 GOOGLE_APPLICATION_CREDENTIALS に定義された JSON ファイル
  2. gcloud コマンドが認識している場所に配置された JSON ファイル
    • macOS/Linux:$HOME/.config/gcloud/application_default_credentials.json
    • Windows:%APPDATA%/gcloud/application_default_credentials.json
  3. GCE / App Engine standard 2nd / Flexible は metadata-server から Credential(デフォルトの GSA トークン)を取得

つまり、ConfigMap で Pod にマウントしている JSON をGOOGLE_APPLICATION_CREDENTIALS に渡してあげることで、前述の OAuth 2.0 Token Exchange による GSA アクセストークンの取得ができます。

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"cloud.google.com/go/storage"
	"google.golang.org/api/iterator"
	"google.golang.org/api/option"
)

const (
	credentialFilePath = "/credentials/gcp.json" // 認証プロファイル(JSON)のパス
	projectID          = ""                      // GCP プロジェクト ID
	bucketName         = ""                      // GCS バケット名
)

func main() {
	ctx := context.Background()

	fmt.Println("[DEBUG] Project ID: ", projectID)

	credentialData, err := os.ReadFile(credentialFilePath)
	if err != nil {
		log.Fatalf("Unable to read the credential file: %v", err)
	}

	fmt.Println("[DEBUG] Credential data: ", string(credentialData))

	// 特定のバケット内のオブジェクトリストを取得
	storageClient, err := storage.NewClient(ctx, option.WithCredentialsJSON(credentialData))
	if err != nil {
		log.Fatalf("Failed to create storage client: %v", err)
	}
	defer storageClient.Close()

	err = listObjects(ctx, storageClient, bucketName)
	if err != nil {
		log.Fatalf("Failed to list objects: %v", err)
	}
}

// 必須ロール: roles/storage.objectViewer (Storage Object Viewer)
func listObjects(ctx context.Context, client *storage.Client, bucketName string) error {
	it := client.Bucket(bucketName).Objects(ctx, nil)

	for {
		objectAttrs, err := it.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
			return fmt.Errorf("error while listing objects: %w", err)
		}

		fmt.Printf("[INFO] Object: %s, Size: %d bytes\n", objectAttrs.Name, objectAttrs.Size)
	}

	return nil
}

このコードを EKS Pod 内で実行すると、GCP SDK が認証プロファイル(gcp.json)を使用して OAuth 2.0 Token Exchange を実行し、GSA のアクセストークンを GCP IAM から取得して GCS へのアクセスを提供します。

クライアントライブラリ(GCP SDK)を使用することで、OAuth 2.0 Token Exchange のフローを隠蔽することができます。 その結果、サービスの開発者は認証情報や複雑なトークン交換のメカニズムを意識することなく、通常の Credential を扱う場合と同じように実装することができます。 これにより、EKS のワークロードから GCP リソースへのキーレス連携を容易に実現することが可能です。

まとめ

本記事では、GCP と他のクラウドプロバイダ間でキーレス認証を実現する GCP Workload Identity Federation の仕組みについて調べてみました。

分散システムやマイクロサービスでは、Kubernetes Pod に対して都度、認証キーを発行するのは、管理やセキュリティの観点から推奨されません。 Workload Identity Federation では、OAuth 2.0 Token Exchange に則ることで、セキュアかつシームレスにクラウドを跨いでコンポーネントを接続することができます。

Workload Identity Federation の仕組みは、GCP クライアントライブラリの中で隠蔽されるため、開発者は複雑なトークン交換のメカニズムを知らずとも利用することができますが、内部の仕組みを理解しておくでトラブルシューティングに役立てることができます。

最近では、IRSA に変わる仕組みとして Pod Identity が、Workload Identity に変わる仕組みとして、Workload Identity Federation for GKE がそれぞれ AWS と GCP から発表されました。 またタイミングを見て触ってみたいと思います。

参考・引用