AWS CLI で SSO profile を元にした AssumeRole profile の設定

AWS CLI の設定 (~/.aws/config) で、 AssumeRole profile の `source_profile` として SSO Profile を指定する、という内容です。

この設定により、IAM User とその Key/Secret なしに、特定の AWS アカウント上のリソースに API 経由でアクセス可能となります。

本記事では、上記の具体的な設定方法を紹介します。
また、その設定を用いて AWSアカウント上に特定のIAM Roleに切り替えて、 aws cli (`aws` コマンド) 実行時もしくは aws-sdk を使ったAWS API呼び出しを行う方法についても記載します。

### AssumeRole Profile の適用事例

この切り替え操作により、例えば、 新規機能のリリース前に、事前にローカル環境で権限設定変更による影響をエミュレートすることなどが可能です。

具体的には、例えば、ローカル開発環境からステージング環境や本番環境のECSTaskRoleに切り替えて開発とテストを行い、権限周りの設定に問題がないかをデプロイ前の開発段階で確認する、といった用途が考えられます。

### 関連記事

本記事の内容は、以下のような AWS 公式ドキュメントに記載のある内容を組み合わせたものです。
詳細については、そちらも併せてご参照ください。

- [AWS | AWS CLI での IAM ロールの使用](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-configure-role.html)
- [AWS Knowledge Center | AWS CLI を使用して IAM ロールを引き受けるにはどうすればよいですか?
](https://aws.amazon.com/jp/premiumsupport/knowledge-center/iam-assume-role-cli/)
- [AWS | AWS IAM Identity Center (successor to AWS Single Sign-On) の自動認証更新によるトークンプロバイダーの設定](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/sso-configure-profile-token.html)

## 事前準備: AssumeRoleの許可

切り替え先のRoleに対して、 `AssumeRole` の実行が許可されている必要があります。
[AWS Knowlede Center の記事](https://aws.amazon.com/jp/premiumsupport/knowledge-center/iam-assume-role-cli/) などを参考に、AssumeRole用の信頼関係（Trust relationship）ポリシーの設定を行なってください。

信頼関係ポリシー例: 同一AWSアカウント内の特定のIAM Roleからの切り替えを許可

```json
{
  "Version": "2012-10-17",
  "Statement": {
    "Effect": "Allow",
    "Principal": {
      "AWS": "{{SSO Profile RoleのARN}}"
    },
    "Action": "sts:AssumeRole"
  }
}
```

なお、既存の IAM Role の信頼関係ポリシー編集は、 AWS Console「IAM」画面から対象のIAM Roleを選択し、 「信頼関係 (Trust relationship)」タブ内からも実施可能です。

既存の IAM Role (test-mail-sender-role) への、信頼ポリシーの設定例:
![329c4cce-19d0-4d91-aeea-249f5386f335.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/434222/9bcdda22-87bc-ed4e-2c65-ce3b2cad386a.png)


## 利用者側設定

requirements:
- aws-cli >= v2.11
    - sso-session の設定に対応するため ([関連issue](https://github.com/aws/aws-cli/issues/7496))

[AWS公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/sso-configure-profile-token.html) などを参考に、 AWS CLIで参照する SSO Profile の設定を行ないます。

以下、 ~/.aws/config に記載する設定例です。

```INI
# SSO認証の共通設定
[sso-session my-sso]
sso_start_url = https://my-sso-portal.awsapps.com/start
sso_registration_scopes = sso:account:access
sso_region = ap-northeast-1

# SSOログインユーザのプロファイル
[profile my-dev-profile]
sso_session = my-sso
sso_account_id = 123456789011
sso_role_name = Dev
region = ap-northeast-1

# AssumeRole (Switch Role) 用のプロファイル設定
# 例: AWS SESからのメール送信のみ可能な `test-mail-sender-role` への AssumeRole profile
[profile mail-sender] 
role_arn = arn:aws:iam::123456789011:role/test-mail-sender-role
source_profile = my-dev-profile
region = ap-northeast-1
```

`my-sso` SSO session で認証を行う `my-dev-profile` を定義し、さらにそれを source_profile に持つ AssumeRole Profile である `example-role` を定義しています。


### 利用例

用途に応じて、 `my-dev-profile` (SSO Profile), `mail-sender` (AssumeRole Profile) をそれぞれ名前付きプロファイルとして指定し aws-cli を実行する、といった利用方法を想定しています。

（名前付きプロファイルの詳しい利用方法は、 https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-configure-profiles.html#using-profiles をなどをご参照ください。）

**共通: SSO login**

```sh
# --sso-session オプションに、設定ファイルに記載した sso-session 名を指定
# 実行するとブラウザが立ち上がって確認画面が表示されるので、 許可ボタンを押す
$ aws sso login --sso-session my-sso
Attempting to automatically open the SSO authorization page in your default browser.
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:

https://device.sso.ap-northeast-1.amazonaws.com/

Then enter the code:
...

Successfully logged into Start URL: ...
```

**通常のSSO Profileでアクセス**

```sh
# SSO Profile （で指定した IAM Role）に対して許可されたリソースへのアクセスに成功
$ aws --profile my-dev-profile s3 ls
2022-05-16 17:56:28 cdk-****
2023-03-06 13:27:05 cf-templates-****
2022-05-16 17:56:33 my-example-bucket-****
...
```

**AssumeRole Profileでのアクセス**

```sh
# AssumeRole先にリソースへのアクセス権限がない場合 => エラー
$ aws --profile mail-sender s3 ls

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

# AssumeRole先に許可された操作（ここではメール送信）の場合 => 成功
$ aws --profile mail-sender ses send-email --from 'test@xxx.example.com' --to 'dst@yyy.example.com' --subject 'test' --text 'this is a test'
{
    "MessageId": "0106********-****-****-****-******-000000"
}
```

## まとめ

AWS SSO Profile を source_profile とする AssumeRole Profile の設定方法および利用例を紹介しました。

この機能を利用することで、個々人への IAM User の作成・割り当てなしに、ローカル開発環境などから、指定した IAM Role 権限で AWS Resource にアクセス可能となります。


## Appendix: aws-sdk-php 

本記事執筆時点で確認したところ、 aws-sdk-php では、SSO Profile を source_profile に指定した AssumeRole Profile設定に対応していませんでした。
（Python, Ruby, JavaScript といった他言語の aws-sdk は対応していました。）

動作確認バージョン: aws-sdk-php v3.263.1

一旦、迂回策として CustomCredentialProvider を実装しました。


e.g. app/AWS/Credentials.php

[aws-sdk-php/src/Credentials/CredentialProvider.php](https://github.com/aws/aws-sdk-php/blob/3.263.1/src/Credentials/CredentialProvider.php) のコードを参考に、AssumeRole Profile の source_profile として SSO Profile を指定できるような CredentialProvider を実装し、 それを defaultCredentialsProviderChain の先頭に追加した CredentialsProviderChain を提供しています。（CustomCredentialsProviderでの認証に失敗した場合は、defaultの認証チェーンに移行します）

```php
<?php

namespace App\Aws;

use Aws\Exception\CredentialsException;
use Aws\Credentials\CredentialProvider;
use Aws\Credentials\AssumeRoleCredentialProvider;
use Aws\Sts\StsClient;
use GuzzleHttp\Promise;

class Credentials
{
    /**
     * SSO ProfileベースのAssumeRole認証設定の読み込みを行うかどうかの判定に利用する環境変数
     * 一旦、 「~/.aws/config のプロファイルを読み込む」指定に利用される aws-sdk-php 独自の
     * 'AWS_SDK_LOAD_NONDEFAULT_CONFIG' という環境変数を共用
     */
    private const ENV_SSO_ASSUMEROLE = 'AWS_SDK_LOAD_NONDEFAULT_CONFIG';

    /**
     * aws-sdk-phpのdefaultCredentialsProvider chainに、SSO+AssumeRoleの CustomCredentialsProviderを追加したものを返します。
     *
     * @see https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials_provider.html#chaining-providers
     * @return callable
     */
    public static function customCredentialProvider($profileName = '', $filename = '', $config = [])
    {
        $chained = CredentialProvider::chain(
            self::ssoAssumeRole($profileName, $filename, $config),
            CredentialProvider::defaultProvider()
        );
        return CredentialProvider::memoize($chained);
    }

    /**
     * SSO Profileをsourceに持つ AssumeRole Profile の認証による STS一時Credentialsを提供します。
     * aws-cli (aws-sdk-python) では標準サポートされている内容ですが、 aws-sdk-php で はv3.261.7 の時点で未サポートのため、
     * 一旦カスタムCredentialsProviderとして実装
     *
     * @see https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials_provider.html#creating-a-custom-provider
     * @return callable
     */
    public static function ssoAssumeRole($profileName = '', $filename = '', $config = [])
    {
        $filename = $filename ?: (self::getHomeDir() . '/.aws/config');
        $profileName = $profileName ?: (getenv(CredentialProvider::ENV_PROFILE) ?: 'default');

        return function () use ($profileName, $filename, $config) {
            if (!getenv(self::ENV_SSO_ASSUMEROLE)) {
                return self::reject('AssumeRole sourced by SSO Profile is not enabled');
            }
            if (!@is_readable($filename)) {
                return self::reject("Cannot read credentials from $filename");
            }

            $profiles = self::loadProfiles($filename);
            if (!isset($profiles['profile ' . $profileName])) {
                return self::reject("NotFound SSO Profile ($profileName) in $filename");
            }

            $profile = $profiles['profile ' . $profileName];
            if (!isset($profile['source_profile']) || !isset($profile['role_arn'])) {
                // phpcs:ignore Generic.Files.LineLength.TooLong
                return self::reject("AssumeRole profile requires 'source_profile' and 'role_arn' but not found in $profileName");
            }
            $region = self::getRegion($profile);

            $ssoProvider = CredentialProvider::sso($profile['source_profile']);
            $provider = new AssumeRoleCredentialProvider([
                'client' => new StsClient([
                    'region' => $region,
                    'version' => '2011-06-15',
                    'credentials' => $ssoProvider,
                ]),
                'assume_role_params' => [
                    'RoleArn' => $profile['role_arn'],
                    'RoleSessionName' => self::roleSessionName(),
                ],
            ]);

            return $provider();
        };
    }

    /**
     * make AWS.STS.RoleSessionName for this app
     */
    private static function roleSessionName()
    {
         return uniqid(implode('-', [getenv('APP_NAME'), getenv('APP_ENV')]));
    }

     /**
     * Gets the environment's HOME directory if available.
     *
     * Copied from AWS\Credentials\CredentialsProvider.
      *
     * @see https://github.com/aws/aws-sdk-php/blob/3.261.17/src/Credentials/CredentialProvider.php#L786-L803
     *
     * @return null|string
     */
    private static function getHomeDir()
    {
        // On Linux/Unix-like systems, use the HOME environment variable
        //
        // OriginalのCredentialProviderのコピーで意図通りの代入操作のため、PHP CSの警告は無視
        // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
        if ($homeDir = getenv('HOME')) {
            return $homeDir;
        }

        // Get the HOMEDRIVE and HOMEPATH values for Windows hosts
        $homeDrive = getenv('HOMEDRIVE');
        $homePath = getenv('HOMEPATH');

        return ($homeDrive && $homePath) ? $homeDrive . $homePath : null;
    }

    /**
     * Gets profiles from specified $filename, or default ini files.
     *
     * Copied from AWS\Credentials\CredentialsProvider.
     *
     * @see https://github.com/aws/aws-sdk-php/blob/3.261.17/src/Credentials/CredentialProvider.php#L805-L828
     */
    private static function loadProfiles($filename)
    {
        $profileData = \Aws\parse_ini_file($filename, true, INI_SCANNER_RAW);

        // If loading .aws/credentials, also load .aws/config when AWS_SDK_LOAD_NONDEFAULT_CONFIG is set
        if (
            $filename === self::getHomeDir() . '/.aws/credentials'
            && getenv('AWS_SDK_LOAD_NONDEFAULT_CONFIG')
        ) {
            $configFilename = self::getHomeDir() . '/.aws/config';
            $configProfileData = \Aws\parse_ini_file($configFilename, true, INI_SCANNER_RAW);
            foreach ($configProfileData as $name => $profile) {
                // standardize config profile names
                $name = str_replace('profile ', '', $name);
                if (!isset($profileData[$name])) {
                    $profileData[$name] = $profile;
                }
            }
        }

        return $profileData;
    }

    /**
     * Find AWS Region for the given AWS Profile.
     *
     * Priority:
     * 1. AWS Profile
     * 2. Laravel config/aws/region
     * 3. AWS_DEFAULT_REGION
     *
     * @return string
     */
    private static function getRegion($profile)
    {
        if (isset($profile['region'])) {
            return $profile['region'];
        }

        return config('aws.region') ?: (getenv('AWS_DEFAULT_REGION') ?: 'us-east-1');
    }

    private static function reject($msg)
    {
        return new Promise\RejectedPromise(new CredentialsException($msg));
    }
}
```

### Laravel (aws-sdk-php-laravel) からの利用例

#### 環境変数

- `AWS_PROFILE` 環境変数に、 設定ファイル (~/.aws/config)  に記載した AssumeRole Profile の名前をセット
- `AWS_SDK_LOAD_NONDEFAULT_CONFIG` 環境変数に true をセット （CustomCredentialProvider 有効化のため）


#### Laravel 設定ファイル

config/aws.php の `credentials` に、CustomCredentialsProvider を指定してください。

例:

```php
<?php

use App\Aws\Credentials;

return [
    // SSO認証+AssumeRole方式に対応できるようにカスタマイズしたCredentialProvider chainを利用
    'credentials' => Credentials::customCredentialProvider(),

    // ...
];
```